Skip to content
← Mastering Foundry — Solidity testing discipline for engineers who already think in Rust
Test discipline
Lesson 2 of 7·CONTENT25 min50 XP
Course
Mastering Foundry — Solidity testing discipline for engineers who already think in Rust
Lesson role
CONTENT
Sequence
2 / 7

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.expectRevert only catches a revert raised by a call the test makes; move the failing operation into the called contract (or opt in with allow_internal_expect_revert = true).

Verbosity, as forge test --help defines it:

FlagWhat it adds
(none)one [PASS] / [FAIL: reason] line per test
-vvlogs (console.log output) for all tests
-vvvexecution traces for failing tests
-vvvvexecution traces for all tests, and setup traces for failing tests
-vvvvvexecution 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 — the cargo test -- --nocapture analogue) / -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.expectRevert has 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 with next call did not revert as expected.
  • The revert must come from the callee (Foundry 1.0+). allow_internal_expect_revert defaults to false, so a revert raised in the test contract itself — e.g. computing zero - 1 as an argument — fails with call didn't revert at a lower depth than cheatcode call depth. Put the underflow inside the called contract (c.decrement()), and Panic(0x11) bubbles up to the cheatcode.
  • Literal arithmetic. Bare 0 - 1 passed as a uint256 is a compile error (int_const -1 can't convert to uint256); uint256(0) - 1 compiles and underflows at runtime. Neither helps with vm.expectRevert if 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 init project shape is the Foundry equivalent of cargo 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 test is the binary that lives at the same place in your daily loop as cargo 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 with test). 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.expectRevert are the three primitives that 90% of unit tests use. assertEq for value equality (with overloads for every Solidity primitive type), assertTrue for boolean conditions, vm.expectRevert for 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] or assert!(matches!(result, Err(_))).
  • Verbosity ramps from no flag (one line per test) to -vvvvv (traces for everything, plus storage changes). -vvv is the daily default — it prints the execution trace of each failing test. -vvvvv is for debugging weird reverts and adds storage changes and backtraces with line numbers. Cross-reference to Rust: cargo test -- --nocapture gives you println! output; Foundry's equivalent is -vv (logs for all tests), and -vvv and 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 from forge init default (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-only CounterWithDecrement contract that gives Counter the decrement() it lacks. Demonstrates vm.expectRevert against 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 the forge, cast, anvil, chisel binaries are on your $PATH.

Lesson 1 starts the test-discipline track. The first verb is forge test.

Plan

Three edits:

  1. forge init my-project && cd my-project — creates the standard Foundry project layout. Read what's produced before touching anything.
  2. Read src/Counter.sol and test/Counter.t.sol as-is — they ship with forge init and demonstrate the conventions. Understand each line.
  3. Append one new test to test/Counter.t.sol: test_RevertWhen_DecrementBelowZero that calls decrement() on a zero-valued counter (via a test-only CounterWithDecrement is Counter subclass), with vm.expectRevert(stdError.arithmeticError) armed beforehand. The underflow happens inside the called contract, which is what vm.expectRevert requires since Foundry 1.0. Run with forge 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:

  1. .t.sol and .s.sol are file-naming conventions, not enforced by the compiler. Foundry treats any contract in test/ whose function names start with test as a test. The .t.sol suffix is a human-readable convention so you can grep *.t.sol to find all your test files. Same convention applies to .s.sol for scripts. Foundry uses naming conventions where Rust uses attributes; the discipline is the same.
  2. lib/forge-std is a git submodule, not an npm/cargo dep. forge init runs forge install foundry-rs/forge-std, which clones it into lib/. 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 can cd lib/forge-std && git log to see exactly what code you're depending on.
  3. out/ and cache/ are gitignored by default. out/ holds compiled bytecode + ABI JSONs (the equivalent of Rust's target/debug/). cache/ holds incremental compilation state. Both are safe to delete and re-generate; both should never be committed.
  4. script/ is for deployment scripts (covered briefly in Lesson 4). Foundry combines testing and scripting under the same forge binary; Hardhat splits them into separate tools (hardhat test vs hardhat 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:

  1. (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.)
  2. (runs: 256, μ: 26956, ~: 29289) — that test is a fuzz test (we'll see why in Lesson 2). runs: 256 means it ran with 256 random inputs; μ is mean gas, ~ is median. Foundry shows fuzz statistics inline.
  3. 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:

  1. 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 more SafeMath), which is what makes our test_RevertWhen_DecrementBelowZero test possible later.
  2. uint256 public number — public auto-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 collapses let pub and let pub fn ...() into one declaration.
  3. No constructor. Default initialization: number = 0. Same default-zero semantics as Rust's i64::default().
  4. setNumber and increment are public — anyone can call them. (Restriction modifiers like onlyOwner would go here in production; the example is intentionally permissionless.)
  5. No decrement function exists. That's a hint. Our new test will add a decrement function in the test file (via a small subclass of Counter), 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:

  1. import {Test} from "forge-std/Test.sol" — Test is the base contract every test inherits from. It bundles assertEq / assertTrue / vm.* cheatcodes etc. The same file also exports console (import {Test, console} ...) — console.log is Foundry's dbg! macro equivalent; its output shows up at -vv and above.
  2. contract CounterTest is Test — your test file is itself a contract that inherits from forge-std's Test. Function inheritance is how you get access to assertEq and vm.*. Solidity's inheritance is the API surface for tooling, where Rust uses traits + use.
  3. function setUp() public — runs before every test function. Same role as Rust's #[test] per-function init, just centralized in one function. One setUp per test contract; if you want per-test setup, you wrap it inside individual test functions.
  4. function test_Increment() public — name starts with test, marked public. 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.
  5. testFuzz_SetNumber(uint256 x) — takes a parameter, so Foundry runs it as a fuzz test (covered in Lesson 2). The parameter is what matters: a test_Foo(uint256) is fuzzed the same way; the testFuzz_ prefix is only a naming convention. setNumber(x) is called with 256 random uint256 values; the assertion must hold for all of them.
  6. assertEq(counter.number(), 1) — equality assertion. forge-std's Test overloads assertEq for 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, no let 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:

  1. test_RevertWhen_<condition> is the naming convention for negative-path tests in Foundry's docs. Not enforced by the test runner — forge test doesn'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.

  2. vm.expectRevert(stdError.arithmeticError) — matches the exact revert data, here Panic(0x11). forge-std's stdError has the other panic codes too. The no-argument vm.expectRevert() matches any revert; use it only when you don't care which reason (we'll match custom errors in Lesson 3).

  3. vm.expectRevert must 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 with next 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.expectRevert has a 1-call lifetime; respect the ordering.

  4. 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 contract
    

    On 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 - 1 is evaluated while building the argument, in the test contract's own frame, so counter.setNumber is never dispatched and the revert happens at the same depth as the cheatcode call. vm.expectRevert only accepts a revert that comes out of a call the test makes (a lower depth). This is controlled by allow_internal_expect_revert, which defaults to false (forge config prints allow_internal_expect_revert = false). Setting it to true in foundry.toml makes the inline version pass, but putting the failing operation in a callee keeps the test honest about which contract reverts. With CounterWithDecrement, forge test -vvvv shows 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 - 1 passed as a uint256 doesn't compile (Error (9553): ... Invalid implicit conversion from int_const -1 to uint256 requested), but uint256(0) - 1 does 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.

  5. The comment block walks the test's intent step-by-step. Same math-walk in comments discipline 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.

  6. No decrement() was added to Counter.sol — the test-only CounterWithDecrement subclass lives in the test file, inherits Counter's storage and functions, and adds just the method the test needs. The production contract stays unchanged. For production contracts with real decrement methods, the test would call counter.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 run forge install foundry-rs/forge-std and lib/forge-std/ is empty. Run it now. (forge init usually does this for you, but a network hiccup can skip it.)
  • [FAIL: next call did not revert as expected] — the call right after vm.expectRevert succeeded. Either it really doesn't revert, or another call (even a view call such as c.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 says import {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:

  1. 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.

  2. Test discovery is by name, not by attribute. Foundry doesn't need @Test annotations because Solidity doesn't have decorators. Functions named test* are tests. The convention is enforced by forge'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.

  3. vm.* cheatcodes are in-EVM calls, not JS-side wrappers. Hardhat's evm_snapshot is an RPC method; Foundry's vm.expectRevert is an ordinary-looking call from the test contract to address 0x7109709ECfa91a80626fF3989D68f67F5b1DD12D (VM_ADDRESS in forge-std's Base.sol). Foundry's test EVM (built on revm, not a fork of it) intercepts calls to that address itself — the trace shows them as VM::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 used vm.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 test passes 3 tests cleanly
  • You've read every line of Counter.sol and Counter.t.sol and understand the conventions
  • You've added a vm.expectRevert test and seen what -vvv shows

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.log output)
  • -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 of prop_assume! for filtering inputs that violate preconditions
  • Shrinking — how Foundry reduces a 32-byte counterexample down to the minimal uint256 that 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-side cargo test. In-process REVM gives sub-second feedback; --match-test / --match-contract / --match-path filter; -vvv is 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.expectRevert has 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 -vvvv trace shows which one reverted.