Skip to content

Core concepts

Mental model

scenario(...steps, testFn) composes fixtures in order and returns a Vitest-compatible async test function.

Each fixture is middleware-like:

  1. read current context
  2. add or replace fields on context
  3. call next(updatedCtx) once

This makes setup explicit and ordered, so every test shows its runtime dependencies in one place.

How context accumulates

Built-in fixtures carry ScenarioStep input/output types, so TypeScript infers the final callback context from fixture order.

Typical progression:

  • after withChain or withFork: runtime, publicClient, walletClient, testClient
  • after withFundedWallet: wallet plus a walletClient bound to that account
  • after withContracts / withDeployments: contracts / deployments

For custom steps, use requireContext(ctx, "publicClient", "wallet") to assert keys at runtime and narrow types.

Fixture categories

Runtime source

  • withChain: start a fresh local Anvil runtime.
  • withFork: start a local fork from rpcUrl and required blockNumber for deterministic replay.
  • withExternalRuntime: attach a caller-owned Anvil handle (from startRuntime) and wire clients without owning lifecycle.

Lifecycle terminology

Use these terms consistently in tests and docs:

  • Managed lifecycle: the fixture starts and stops Anvil (withChain, withFork).
  • External lifecycle: test hooks (beforeAll / afterAll) own startRuntime / stopRuntime, and scenarios attach via withExternalRuntime.
  • Suite-scoped handle: one external RuntimeHandle reused across tests in one file or describe.
  • Snapshot isolation: use withSnapshot() inside each scenario when a suite-scoped handle is reused across mutating tests.

Wallet and balance setup

  • withFundedWallet: create or reuse an account, fund ETH, and expose wallet.
  • withFundedWallet({ erc20: [...] }): seed ERC-20 balances for the scenario wallet in the same step.
  • withErc20Balance: seed ERC-20 for a specific recipient (to) or default to ctx.wallet.

Isolation

  • withSnapshot: create a snapshot before inner steps and always revert in finally.
  • Use it when you want tighter isolation for a test section while keeping one runtime lifecycle.

Contract state options

  • withContracts: set runtime bytecode at known addresses for fast setup.
  • withDeployments: execute real deployments with constructor semantics.

Choosing between common fixtures

NeedFixture
Fast local execution, no external RPCwithChain
Real chain state, deterministic replaywithFork + pinned blockNumber
Reuse one caller-owned Anvil process across tests in a filewithExternalRuntime + withSnapshot
Wallet ETH fundingwithFundedWallet
Wallet token balances in same stepwithFundedWallet({ erc20 })
Token balance for arbitrary addresswithErc20Balance({ to })
Isolate side effects within an existing runtimewithSnapshot
Fast contract setup without constructorswithContracts
Deployment-realistic setupwithDeployments

Scope

Statecraft is intentionally focused on explicit EVM testing workflows for TypeScript + Vitest + viem. It is not a replacement for Foundry or Hardhat.

For a minimal first scenario, use the Quickstart.

ERC-20 balance dealing

withErc20Balance and withFundedWallet.erc20 write token balance state directly on compatible local/forked test nodes (for example Anvil), so tests can skip mint/transfer setup.

This is test-only state manipulation, not production issuance, and can fail for unusual token designs (rebasing, non-standard storage, proxy edge cases).

Ordering notes:

  • If you omit to, TypeScript expects a prior step that sets ctx.wallet (usually withFundedWallet).
  • If you pass to, only runtime clients from withChain / withFork are required.
import { test, expect } from "vitest";
import { erc20Abi, parseEther } from "viem";
import { scenario, withFork, withFundedWallet, withErc20Balance } from "@statecraft/vitest";
 
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0ce3606eB48";
 
test(
  "funded wallet has USDC",
  scenario(
    withFork({
      rpcUrl: process.env.MAINNET_RPC_URL!,
      blockNumber: 22_000_000n,
    }),
    withFundedWallet({ balance: parseEther("1") }),
    withErc20Balance({
      token: USDC,
      amount: 1_000_000n,
    }),
    async ({ wallet, publicClient }) => {
      const bal = await publicClient.readContract({
        address: USDC,
        abi: erc20Abi,
        functionName: "balanceOf",
        args: [wallet],
      });
      expect(bal).toBe(1_000_000n);
    },
  ),
);

For upgrade-specific changes, see Migration.