Skip to content
LogoLogo

Testing with Vitest

A TevmProvider boots in milliseconds and needs no binary, no port, and no beforeAll process management. That makes it a good default backend for tests of any code that takes an ethers provider or signer.

Setup

pnpm add -D vitest
pnpm add @tevm/ethers ethers tevm viem@2.46.0
// vitest.config.ts
import {  } from 'vitest/config'
 
export default ({
	: {
		: 'node',
		: 20_000,
	},
})

Forked tests hit the network on first read, so a timeout above the default is worth setting.

A fresh EVM per test

Create the provider in beforeEach so no test can observe another test's state.

import {  } from '@tevm/ethers'
import {  } from '@tevm/utils'
import { , ,  } from 'ethers'
import { , , ,  } from 'vitest'
 
('payments', () => {
	let : 
 
	(async () => {
		 = await .createMemoryProvider({
			: { : 'auto' },
		})
	})
 
	('transfers ether', async () => {
		const  = new ([0], )
		const  = `0x${'42'.(20)}`
 
		const  = await .({ : , : ('1') })
		const  = await .()
 
		(?.).(1)
		((await .getBalance())).('1.0')
	})
 
	('starts from a clean slate', async () => {
		(await .getBalance(`0x${'42'.(20)}`)).(0n)
	})
})

Testing a contract

import { ,  } from '@tevm/ethers'
import {  } from '@tevm/contract'
import { ,  } from '@tevm/utils'
import {  } from 'ethers'
import { , , ,  } from 'vitest'
 
('ERC20', () => {
	let : 
	let : 
 
	(async () => {
		 = await .createMemoryProvider({
			: { : 'auto' },
		})
 
		const  = await .tevm.deploy({
			....('Token', 'TKN'),
			: .,
			: .,
			: [0].,
			: true,
		})
 
		await .tevm.deal({
			: .createdAddress,
			: [0].,
			: 1000n,
		})
 
		 = new (.createdAddress, ., new ([0], ))
	})
 
	('exposes metadata', async () => {
		(await .name()).('Token')
		(await .symbol()).('TKN')
		(await .decimals()).(18n)
	})
 
	('emits Transfer on transfer', async () => {
		const  = await .transfer(`0x${'42'.(20)}`, 100n)
		await .wait()
 
		(await .balanceOf(`0x${'42'.(20)}`)).(100n)
 
		const  = await .queryFilter('Transfer', 0, 'latest')
		().(1)
		([0].args.value).(100n)
	})
})

Forked tests without repeated RPC cost

Fork once, dump the state, and reload it in each test. After the first run nothing touches the network.

import {  } from '@tevm/ethers'
import {  } from 'viem'
import { , , , ,  } from 'vitest'
 
('forked mainnet', () => {
	let : <<['tevm']['dumpState']>>
	let : 
 
	(async () => {
		const  = await .createMemoryProvider({
			: {
				: ('https://mainnet.optimism.io')({}),
				: 154_847_000n,
			},
		})
 
		// Touch the state the tests need so it is captured in the dump.
		await .getBalance('0x0000000000000000000000000000000000000001')
 
		 = await .tevm.dumpState()
	})
 
	(async () => {
		 = await .createMemoryProvider({ : { : 'auto' } })
		await .tevm.loadState({ : .state })
	})
 
	('sees forked balances', async () => {
		(typeof (await .getBalance('0x0000000000000000000000000000000000000001'))).('bigint')
	})
})

Tips

  • Pin the fork block. An unpinned fork makes tests non-deterministic.
  • Prefer miningConfig: { type: 'auto' } in tests unless you are specifically testing mempool behavior; otherwise tx.wait() never resolves until you call provider.tevm.mine().
  • Use deal instead of impersonating a whale when you only need a balance — it is faster and has no upstream dependency.
  • Don't share a provider across test files. Each file gets its own process-level module state in Vitest, but sharing within a file leaks state between tests.