Introduction to State Management in Vitest
Vitest has rapidly become one of the most popular testing frameworks for modern JavaScript and TypeScript projects. Built on top of Vite, it offers blazing-fast execution, native ESM support, and a Jest-compatible API. However, as your test suite grows, one challenge consistently emerges: managing state across tests, files, and concurrent workers. State management in Vitest is not about Redux or Pinia โ it is about how your tests share, isolate, and reset data during execution.
Whether you are running unit tests, integration tests, or end-to-end scenarios, understanding how Vitest handles state is critical. Poor state management leads to flaky tests, race conditions, and debugging nightmares. This tutorial explores the patterns, built-in APIs, and third-party libraries that help you keep your test state clean, predictable, and scalable.
Why State Management Matters in Testing
Tests do not exist in a vacuum. They often interact with databases, file systems, global variables, mocks, and shared modules. If one test mutates shared state and another test depends on a clean baseline, you have a recipe for intermittent failures. These failures are particularly painful because they may pass locally but fail in CI, or pass in isolation but fail when the full suite runs.
Common State-Related Problems
- Test pollution: A test modifies a global object, and subsequent tests inherit the dirty state.
- Order dependency: Tests pass only when run in a specific sequence.
- Concurrency issues: Parallel tests write to the same resource simultaneously.
- Mock leakage: Mocked modules persist beyond their intended scope.
- Memory bloat: References are never released, causing slow or crashing test runs.
By applying deliberate state management patterns, you can eliminate these issues and build a test suite that is reliable regardless of execution order or parallelism.
Understanding Vitest's Execution Model
Before diving into patterns, it is essential to understand how Vitest executes tests. By default, Vitest runs test files in parallel using worker threads. Each test file runs in its own isolated environment, meaning module-level state does not leak between files. However, tests within the same file share the same module scope by default.
Vitest provides several isolation levels:
- File-level isolation: Each test file runs in a separate module registry.
- Test-level isolation: Controlled by the
isolateoption, which resets the module registry between tests within a file. - Environment isolation: Each file can run in a different environment (node, jsdom, happy-dom).
You can configure isolation in your vitest.config.ts:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
isolate: true, // Reset module registry between tests
pool: 'threads', // Use worker threads for parallelism
poolOptions: {
threads: {
maxThreads: 4,
minThreads: 1,
},
},
},
})
When isolate is true, Vitest resets the module registry between tests, ensuring that imported modules are re-evaluated. This is powerful but comes with a performance cost. For most projects, file-level isolation is sufficient, and you can manage test-level state manually using the patterns below.
Built-in State Management APIs
The beforeEach and afterEach Hooks
The most fundamental state management tool in Vitest is the lifecycle hooks. beforeEach and afterEach run before and after every test, allowing you to set up and tear down state consistently.
import { beforeEach, afterEach, describe, it, expect } from 'vitest'
describe('User Service', () => {
let users: Array<{ id: number; name: string }>
beforeEach(() => {
users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]
})
afterEach(() => {
users = []
})
it('should add a user', () => {
users.push({ id: 3, name: 'Charlie' })
expect(users).toHaveLength(3)
})
it('should start with two users', () => {
expect(users).toHaveLength(2)
})
})
Notice that the second test still sees two users even though the first test added one. This is because beforeEach re-initializes the array before each test runs.
The beforeAll and afterAll Hooks
For expensive setup that can be shared across tests, use beforeAll and afterAll. These run once per describe block (or file, if at the top level). Be cautious: any state mutated in individual tests will persist for subsequent tests.
import { beforeAll, afterAll, describe, it, expect } from 'vitest'
import { createDatabase } from './db'
describe('Database integration', () => {
let db: ReturnType<typeof createDatabase>
beforeAll(async () => {
db = await createDatabase('sqlite::memory:')
await db.migrate()
})
afterAll(async () => {
await db.close()
})
it('should insert a record', async () => {
await db.insert('users', { name: 'Alice' })
const count = await db.count('users')
expect(count).toBe(1)
})
it('should find the record', async () => {
const user = await db.find('users', { name: 'Alice' })
expect(user).toBeDefined()
})
})
In this example, the second test relies on the state created by the first. This is acceptable for integration tests where the sequence is intentional, but it introduces order dependency. If you need isolation, reset the database in beforeEach instead.
Managing Global State with globalThis
Sometimes you need to share state across test files. Vitest does not provide a built-in cross-file state container, but you can use globalThis or environment variables for simple cases. However, this approach is fragile because each test file runs in its own worker, so globalThis mutations do not propagate between files.
import { beforeEach, afterEach, it, expect } from 'vitest'
declare global {
var __testContext: { requestId: number } | undefined
}
beforeEach(() => {
globalThis.__testContext = { requestId: Math.floor(Math.random() * 100000) }
})
afterEach(() => {
delete globalThis.__testContext
})
it('should have a unique request id', () => {
expect(globalThis.__testContext?.requestId).toBeGreaterThan(0)
})
For cross-file state, prefer external systems like a database, a file on disk, or an in-memory server that all workers can access.
Pattern 1: Factory Functions for Fresh State
One of the cleanest patterns for state management is using factory functions. Instead of declaring a mutable variable and resetting it in hooks, you create a function that returns a fresh state object whenever called.
import { it, expect, describe } from 'vitest'
function createCart() {
const items: Array<{ id: string; price: number; quantity: number }> = []
return {
items,
add(item: { id: string; price: number; quantity: number }) {
const existing = items.find((i) => i.id === item.id)
if (existing) {
existing.quantity += item.quantity
} else {
items.push({ ...item })
}
},
total() {
return items.reduce((sum, i) => sum + i.price * i.quantity, 0)
},
clear() {
items.length = 0
},
}
}
describe('Shopping Cart', () => {
it('should calculate total for single item', () => {
const cart = createCart()
cart.add({ id: 'a', price: 10, quantity: 2 })
expect(cart.total()).toBe(20)
})
it('should merge duplicate items', () => {
const cart = createCart()
cart.add({ id: 'a', price: 10, quantity: 1 })
cart.add({ id: 'a', price: 10, quantity: 2 })
expect(cart.items).toHaveLength(1)
expect(cart.items[0].quantity).toBe(3)
})
it('should start empty every time', () => {
const cart = createCart()
expect(cart.items).toHaveLength(0)
expect(cart.total()).toBe(0)
})
})
This pattern eliminates the need for beforeEach resets because each test creates its own isolated instance. It is simple, explicit, and immune to order dependencies.
Pattern 2: The Setup Object Pattern
For more complex scenarios where multiple pieces of state need to be coordinated, a setup object pattern works well. This is essentially a lightweight dependency injection container for your tests.
import { it, expect, describe, beforeEach } from 'vitest'
import { createUserService } from './userService'
import { createMockRepository } from './mockRepository'
interface TestSetup {
repository: ReturnType<typeof createMockRepository>
userService: ReturnType<typeof createUserService>
cleanup: () => void
}
function setupTestEnvironment(): TestSetup {
const repository = createMockRepository()
const userService = createUserService(repository)
return {
repository,
userService,
cleanup: () => {
repository.reset()
},
}
}
describe('User Service with Setup Object', () => {
let setup: TestSetup
beforeEach(() => {
setup = setupTestEnvironment()
})
it('should create a user', async () => {
const user = await setup.userService.create({ name: 'Alice', email: 'alice@test.com' })
expect(user.id).toBeDefined()
expect(setup.repository.records).toHaveLength(1)
})
it('should reject duplicate emails', async () => {
setup.repository.seed([{ id: '1', name: 'Alice', email: 'alice@test.com' }])
await expect(
setup.userService.create({ name: 'Bob', email: 'alice@test.com' })
).rejects.toThrow('Email already exists')
})
})
The setup object bundles all dependencies together, making it easy to see what each test has access to. The cleanup function provides a single point for tearing down resources.
Pattern 3: Snapshot State with vi.snapshot
Vitest's snapshot functionality is itself a form of state management. Snapshots store expected output and compare it on subsequent runs. While typically used for serialization, snapshots can also validate that state transitions produce consistent results.
import { it, expect, describe } from 'vitest'
import { renderState } from './stateRenderer'
describe('State snapshots', () => {
it('matches initial state', () => {
const state = {
users: [],
loading: false,
error: null,
}
expect(renderState(state)).toMatchInlineSnapshot(`
"Users: 0 | Loading: false | Error: none"
`)
})
it('matches loading state', () => {
const state = {
users: [],
loading: true,
error: null,
}
expect(renderState(state)).toMatchInlineSnapshot(`
"Users: 0 | Loading: true | Error: none"
`)
})
})
Use snapshots judiciously. They are excellent for catching unexpected state changes but can become a maintenance burden if overused.
Pattern 4: Mock State Management
Mocks are a common source of state leakage. Vitest provides vi.fn(), vi.spyOn(), and vi.mock() for mocking. The key to clean mock state is proper restoration.
import { it, expect, describe, beforeEach, afterEach, vi } from 'vitest'
import { api } from './api'
import { userStore } from './userStore'
describe('User Store with Mocks', () => {
beforeEach(() => {
vi.mocked(api.fetchUsers).mockReset()
vi.mocked(api.createUser).mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
userStore.reset()
})
it('loads users from API', async () => {
vi.mocked(api.fetchUsers).mockResolvedValue([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
])
await userStore.load()
expect(userStore.users).toHaveLength(2)
expect(api.fetchUsers).toHaveBeenCalledOnce()
})
it('handles API errors', async () => {
vi.mocked(api.fetchUsers).mockRejectedValue(new Error('Network error'))
await userStore.load()
expect(userStore.users).toHaveLength(0)
expect(userStore.error).toBe('Network error')
})
})
The mockReset() call clears both the mock's call history and its implementation, ensuring each test starts with a clean mock. vi.restoreAllMocks() in afterEach restores all spied methods to their original implementations.
Module-Level Mocks with vi.mock
When using vi.mock(), the mock is applied at the module level and persists for the entire file. This is by design, but it means you need to configure mock behavior inside individual tests or hooks.
import { it, expect, describe, beforeEach, vi } from 'vitest'
vi.mock('./database', () => ({
db: {
query: vi.fn(),
insert: vi.fn(),
delete: vi.fn(),
},
}))
import { db } from './database'
import { userRepository } from './userRepository'
describe('User Repository', () => {
beforeEach(() => {
vi.mocked(db.query).mockReset()
vi.mocked(db.insert).mockReset()
vi.mocked(db.delete).mockReset()
})
it('finds users by email', async () => {
vi.mocked(db.query).mockResolvedValue([{ id: 1, email: 'test@example.com' }])
const users = await userRepository.findByEmail('test@example.com')
expect(users).toHaveLength(1)
expect(db.query).toHaveBeenCalledWith('SELECT * FROM users WHERE email = ?', ['test@example.com'])
})
})
Pattern 5: Using Vitest's Built-in Context
Vitest provides a context object in each test that can be used to store per-test state. This is available via the second argument to it and is particularly useful for custom test extensions.
import { it, expect, describe } from 'vitest'
describe('Test context state', () => {
it('should store data in context', ({ task }) => {
// The task object contains metadata about the current test
expect(task.name).toBe('should store data in context')
})
it('can use custom context via extensions', (ctx) => {
// Custom context properties can be added via Vitest plugins
expect(ctx).toBeDefined()
})
})
For more advanced use cases, you can extend the test context with custom properties using Vitest's plugin system:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
setupFiles: ['./test-setup.ts'],
},
})
// test-setup.ts
import { beforeEach } from 'vitest'
interface CustomContext {
testData: Map<string, unknown>
}
declare module 'vitest' {
export interface TestContext {
testData: Map<string, unknown>
}
}
beforeEach((ctx) => {
ctx.testData = new Map()
})
// example.test.ts
import { it, expect } from 'vitest'
it('uses custom context', (ctx) => {
ctx.testData.set('user', { id: 1, name: 'Alice' })
expect(ctx.testData.get('user')).toEqual({ id: 1, name: 'Alice' })
})
it('has fresh context each time', (ctx) => {
expect(ctx.testData.size).toBe(0)
})
Libraries for Test State Management
1. MSW (Mock Service Worker)
MSW is invaluable for managing HTTP request state in tests. It intercepts network requests and returns mocked responses, allowing you to control API state without a real server.
import { it, expect, describe, beforeAll, afterAll, afterEach } from 'vitest'
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
import { apiClient } from './apiClient'
const server = setupServer(
http.get('https://api.example.com/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
])
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe('API Client with MSW', () => {
it('fetches users', async () => {
const users = await apiClient.getUsers()
expect(users).toHaveLength(2)
})
it('handles server errors', async () => {
server.use(
http.get('https://api.example.com/users', () => {
return HttpResponse.json({ error: 'Internal Server Error' }, { status: 500 })
})
)
await expect(apiClient.getUsers()).rejects.toThrow()
})
})
The server.resetHandlers() call in afterEach ensures that any test-specific handlers added via server.use() are removed, preventing state leakage between tests.
2. Test Containers
For integration tests that need a real database, Testcontainers provides disposable containers that are created and destroyed per test run. This gives you real database state management without the overhead of a persistent test database.
import { it, expect, describe, beforeAll, afterAll } from 'vitest'
import { PostgreSqlContainer } from '@testcontainers/postgresql'
import { Pool } from 'pg'
describe('PostgreSQL integration', () => {
let container: InstanceType<typeof PostgreSqlContainer>
let pool: Pool
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16-alpine')
.withDatabase('testdb')
.start()
pool = new Pool({
host: container.getHost(),
port: container.getPort(),
database: container.getDatabase(),
user: container.getUsername(),
password: container.getPassword(),
})
await pool.query(`
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL
)
`)
})
afterAll(async () => {
await pool.end()
await container.stop()
})
it('inserts and retrieves a user', async () => {
await pool.query(
'INSERT INTO users (name, email) VALUES ($1, $2)',
['Alice', 'alice@test.com']
)
const result = await pool.query('SELECT * FROM users WHERE email = $1', ['alice@test.com'])
expect(result.rows).toHaveLength(1)
expect(result.rows[0].name).toBe('Alice')
})
it('starts with a clean table', async () => {
const result = await pool.query('SELECT * FROM users')
// This test may see data from the previous test unless you clean up
// Consider truncating in beforeEach for full isolation
expect(result.rows.length).toBeGreaterThanOrEqual(0)
})
})
For full isolation, truncate tables in a beforeEach hook:
beforeEach(async () => {
await pool.query('TRUNCATE users RESTART IDENTITY CASCADE')
})
3. Nock for HTTP Mocking
As an alternative to MSW, Nock provides a direct way to intercept HTTP requests. It requires careful cleanup to prevent state leakage.
import { it, expect, describe, afterEach } from 'vitest'
import nock from 'nock'
import { apiClient } from './apiClient'
describe('API Client with Nock', () => {
afterEach(() => {
nock.cleanAll()
})
it('fetches a user by id', async () => {
nock('https://api.example.com')
.get('/users/1')
.reply(200, { id: 1, name: 'Alice' })
const user = await apiClient.getUser(1)
expect(user.name).toBe('Alice')
expect(nock.isDone()).toBe(true)
})
})
The nock.cleanAll() call is essential โ without it, interceptors from one test can bleed into the next.
4. Memfs for Filesystem State
When testing code that interacts with the filesystem, use memfs to create an in-memory filesystem. This avoids polluting the real filesystem and makes cleanup trivial.
import { it, expect, describe, beforeEach, vi } from 'vitest'
import { fs as memfs, vol } from 'memfs'
import { configLoader } from './configLoader'
describe('Config Loader with Memfs', () => {
beforeEach(() => {
vol.reset()
vi.mock('node:fs', () => memfs)
vi.mock('fs', () => memfs)
})
it('loads JSON config', async () => {
vol.fromJSON({
'/app/config.json': JSON.stringify({ port: 3000, debug: true }),
})
const config = await configLoader.load('/app/config.json')
expect(config.port).toBe(3000)
expect(config.debug).toBe(true)
})
it('throws on missing file', async () => {
await expect(configLoader.load('/app/missing.json')).rejects.toThrow()
})
})
Best Practices for State Management in Vitest
1. Prefer Isolation Over Sharing
The golden rule of test state management is: each test should be independent. Avoid sharing mutable state between tests. If tests must share expensive setup, use beforeAll for read-only resources and beforeEach for anything that might be mutated.
2. Always Clean Up in afterEach
Never assume the next test will clean up after the previous one. Use afterEach to reset mocks, clear timers, close connections, and restore modified globals. This is especially important when tests run in random order.
let originalEnv: NodeJS.ProcessEnv
beforeEach(() => {
originalEnv = { ...process.env }
})
afterEach(() => {
process.env = originalEnv
vi.useRealTimers()
vi.restoreAllMocks()
})
3. Use vi.useFakeTimers Carefully
Fake timers are a form of global state. If you enable them in one test and forget to restore them, subsequent tests may hang or behave unexpectedly.
import { it, expect, describe, beforeEach, afterEach, vi } from 'vitest'
describe('Timer-based logic', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('executes callback after delay', () => {
const callback = vi.fn()
setTimeout(callback, 1000)
vi.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalledOnce()
})
})
4. Avoid Top-Level Mutable State
Mutable variables declared at the top level of a test file are shared across all tests in that file. If you must use them, always reset them in beforeEach.
// Bad: shared mutable state without reset
let counter = 0
it('increments', () => {
counter++
expect(counter).toBe(1) // Passes alone, fails if run after another test
})
// Good: reset in beforeEach
let counter: number
beforeEach(() => {
counter = 0
})
it('increments', () => {
counter++
expect(counter).toBe(1) // Always passes
})
5. Leverage Dependency Injection
Design your application code to accept dependencies as parameters rather than importing them directly. This makes it trivial to inject fresh state in tests without relying on module mocking.
// userService.ts
export interface UserRepository {
findById(id: string): Promise<User | null>
save(user: User): Promise<void>
}
export function createUserService(repo: UserRepository) {
return {
async getUser(id: string) {
return repo.findById(id)
},
async updateUser(id: string, data: Partial<User>) {
const user = await repo.findById(id)
if (!user) throw new Error('User not found')
await repo.save({ ...user, ...data })
},
}
}
// userService.test.ts
import { it, expect, describe } from 'vitest'
import { createUserService, UserRepository } from './userService'
function createMockRepo(): UserRepository {
const store = new Map<string, User>()
return {
async findById(id) {
return store.get(id) ?? null
},
async save(user) {
store.set(user.id, user)
},
}
}
describe('User Service with DI', () => {
it('updates a user', async () => {
const repo = createMockRepo()
await repo.save({ id: '1', name: 'Alice', email: 'alice@test.com' })
const service = createUserService(repo)
await service.updateUser('1', { name: 'Alice Updated' })
const updated = await service.getUser('1')
expect(updated?.name).toBe('Alice Updated')
})
})
6. Use Random Test Order to Catch State Leaks
Vitest does not randomize test order by default, but you can use the --shuffle flag or configure it in your config file. Running tests in random order is an excellent way to catch hidden state dependencies.
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
sequence: {
shuffle: true,
},
},
})
If your tests fail with shuffling enabled but pass without it, you have a state management problem that needs fixing.
7. Profile Memory Usage for Large Suites
For large test suites, memory leaks from uncleaned state can cause performance degradation. Vitest provides a --reporter option and you can monitor memory usage with Node's built-in tools.
// In a setup file
import { afterAll } from 'vitest'
afterAll(() => {
if (global.gc) {
global.gc()
}
const used = process.memoryUsage().heapUsed / 1024 / 1024
console.log(`Memory usage: ${Math.round(used * 100) / 100} MB`)
})
Advanced Pattern: State Machines for Complex Test Scenarios
For tests that involve complex state transitions (such as multi-step workflows), consider modeling the test state as a finite state machine. Libraries like XState can help, but even a simple enum-based approach works well.
import { it, expect, describe } from 'vitest'
type OrderState = 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled'
function createOrderStateMachine() {
let state: OrderState = 'pending'
const transitions: Record<OrderState, OrderState[]> = {
pending: ['confirmed', 'cancelled'],
confirmed: ['shipped', 'cancelled'],
shipped: ['delivered'],
delivered: [],
cancelled: [],
}
return {
getState: () => state,
transition(next: OrderState) {
const allowed = transitions[state]
if (!allowed.includes(next)) {
throw new Error(`Cannot transition from ${state} to ${next}`)
}
state = next
},
}
}
describe('Order State Machine', () => {
it('follows valid transition path', () => {
const order = createOrderStateMachine()
expect(order.getState()).toBe('pending')
order.transition('confirmed')
expect(order.getState()).toBe('confirmed')
order.transition('shipped')
expect(order.getState()).toBe('shipped')
order.transition('delivered')
expect(order.getState()).toBe('delivered')
})
it('rejects invalid transitions', () => {
const order = createOrderStateMachine()
expect(() => order.transition('delivered')).toThrow('Cannot transition from pending to delivered')
})
it('allows cancellation from pending', () => {
const order = createOrderStateMachine()
order.transition('cancelled')
expect(order.getState()).toBe('cancelled')
})
})
Each test creates its own state machine instance, ensuring complete isolation. The state machine pattern makes it explicit which transitions are valid, reducing the chance of testing impossible scenarios.
Conclusion
State management in Vitest is a discipline that pays dividends as your test suite grows. By understanding Vitest's isolation model, leveraging lifecycle hooks, using factory functions, and applying cleanup consistently, you can build a test suite that is fast, reliable, and maintainable. The key principles are simple: prefer isolation over sharing, always clean up after yourself, use dependency injection to make state injection trivial, and periodically run tests in random order to catch hidden dependencies. Whether you rely on Vitest's built-in APIs or complement them with libraries like MSW, Testcontainers, or memfs, the goal remains the same โ tests that pass deterministically regardless of execution order, parallelism, or environment. Invest in clean state management early, and your test suite will remain a source of confidence rather than frustration.