Core concepts
Mental model
scenario(...steps, testFn) composes fixtures in order and returns a
Vitest-compatible async test function.
Each fixture is middleware-like:
- read current context
- add or replace fields on context
- 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
withChainorwithFork:runtime,publicClient,walletClient,testClient - after
withFundedWallet:walletplus awalletClientbound 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 fromrpcUrland requiredblockNumberfor deterministic replay.withExternalRuntime: attach a caller-owned Anvil handle (fromstartRuntime) 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) ownstartRuntime/stopRuntime, and scenarios attach viawithExternalRuntime. - Suite-scoped handle: one external
RuntimeHandlereused across tests in one file ordescribe. - 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 exposewallet.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 toctx.wallet.
Isolation
withSnapshot: create a snapshot before inner steps and always revert infinally.- 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
| Need | Fixture |
|---|---|
| Fast local execution, no external RPC | withChain |
| Real chain state, deterministic replay | withFork + pinned blockNumber |
| Reuse one caller-owned Anvil process across tests in a file | withExternalRuntime + withSnapshot |
| Wallet ETH funding | withFundedWallet |
| Wallet token balances in same step | withFundedWallet({ erc20 }) |
| Token balance for arbitrary address | withErc20Balance({ to }) |
| Isolate side effects within an existing runtime | withSnapshot |
| Fast contract setup without constructors | withContracts |
| Deployment-realistic setup | withDeployments |
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 setsctx.wallet(usuallywithFundedWallet). - If you pass
to, only runtime clients fromwithChain/withForkare 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.