Patterns for writing comprehensive Vitest test suites in TypeScript projects. Covers shared test utilities, vi.mock for module mocking, fixture directories, provider/adapter testing, and state management testing. Tier - generic.
Scanned 9/10/2026
Install to Claude Code
npx -y skills add krafteq/infraglue --skill vitest-test-patterns --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Vitest Test Patterns?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/krafteq-vitest-test-patterns)More formats (shields.io, HTML) on the badges page.
---
name: vitest-test-patterns
description: Patterns for writing comprehensive Vitest test suites in TypeScript projects. Covers shared test utilities, vi.mock for module mocking, fixture directories, provider/adapter testing, and state management testing. Tier - generic.
user-invokable: false
---
# Vitest Test Patterns
## Goal
Establish reusable patterns for writing comprehensive test suites with Vitest in TypeScript ESM projects.
## Shared Test Utilities
Create `src/__test-utils__/` with reusable helpers:
```ts
// __test-utils__/mock-adapter.ts
import { vi } from 'vitest'
import type { IAdapter } from '../adapters/adapter.js'
export class MockAdapter implements IAdapter {
// Use vi.fn() for every method — enables spying, return value control, call assertions
doWork = vi.fn<IAdapter['doWork']>((): Promise<Result> => {
throw new Error('Method not implemented.')
})
getName = vi.fn(() => 'mock')
}
// Factory helpers with sensible defaults
export function createThing(name: string, overrides: Partial<ThingConfig> = {}): Thing {
return new Thing(name, `/path/to/${name}`, { env: 'dev', ...overrides })
}
```
**Key pattern:** Use `vi.fn<InterfaceType['methodName']>()` for type-safe mock methods.
## Module Mocking with vi.mock
### Mocking a dependency with shared state
When the SUT creates its own instance of a dependency internally, mock the module and use shared variables to control behavior:
```ts
const mockRead = vi.fn()
const mockUpdate = vi.fn()
vi.mock('./state-manager.js', async (importOriginal) => {
const original = await importOriginal<typeof import('./state-manager.js')>()
return {
...original, // Preserve non-mocked exports (e.g., State class)
StateManager: vi.fn().mockImplementation(() => ({
read: mockRead,
update: mockUpdate,
})),
}
})
```
### Gotcha: Shared state across mock calls
When mocking `update(fn)` where `fn` mutates state, use a **shared state object** so mutations from one call are visible to the next:
```ts
// WRONG — each call gets a fresh object, mutations are lost
mockUpdate.mockImplementation(async (fn) => {
fn(new State()) // ← startSelectingEnv() lost before finishEnvSelection()
})
// RIGHT — shared state preserves mutations across calls
let sharedState = new State()
mockUpdate.mockImplementation(async (fn) => {
fn(sharedState)
})
```
## Static Fixture Directories
For filesystem-dependent code (config parsers, file scanners), use static fixture dirs checked into the repo:
```text
src/core/__fixtures__/
simple-case/ # Happy path
config.yaml
sub-dir/config.yaml
edge-case/ # Error paths, missing files
config.yaml
complex-case/ # Multiple interacting components
config.yaml
a/config.yaml
b/config.yaml
```
Reference fixtures with `import.meta.dirname`:
```ts
const FIXTURES_DIR = resolve(import.meta.dirname, '__fixtures__')
it('should parse config', async () => {
const result = await parseConfig(join(FIXTURES_DIR, 'simple-case'))
expect(result.items).toHaveLength(2)
})
```
**When to use temp dirs instead:** For tests that write files (state persistence, temp files). Use `mkdtemp` + `afterEach` cleanup:
```ts
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'test-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true })
})
```
## Extract Pure Functions for Testability
When a class has complex private parsing/transformation logic, extract it as an **exported pure function** while keeping the class method as a one-line delegate:
```ts
// Before: private method, untestable directly
class MyProvider {
private parseOutput(raw: string): Plan {
/* 80 lines of parsing */
}
}
// After: exported pure function + thin delegate
class MyProvider {
private parseOutput(raw: string): Plan {
return parseProviderOutput(raw, this.name)
}
}
export function parseProviderOutput(raw: string, name: string): Plan {
/* same 80 lines, now directly testable */
}
```
Test the exported function with fixture data:
```ts
// __test-utils__/provider-fixtures.ts — raw JSON strings representing real CLI output
export const PROVIDER_OUTPUT_CREATE = '{"type":"create","resources":[...]}'
export const PROVIDER_OUTPUT_NO_CHANGES = '{"type":"no-op"}'
// provider.test.ts
import { parseProviderOutput } from './provider.js'
import { PROVIDER_OUTPUT_CREATE } from '../__test-utils__/provider-fixtures.js'
it('should parse create output', () => {
const plan = parseProviderOutput(PROVIDER_OUTPUT_CREATE, 'my-project')
expect(plan.changes).toHaveLength(2)
expect(plan.changes[0].action).toBe('create')
})
```
## Orchestration/Integration-Style Tests
For testing multi-step orchestrators that coordinate multiple subsystems:
1. Mock all dependencies at the module level
2. Use `beforeEach` with `vi.clearAllMocks()` for isolation
3. Set up preconditions with mock return values per test
4. Assert the interaction sequence: calls made, call order, arguments passed
```ts
it('should execute level 1 then level 2', async () => {
mockGetPlan.mockResolvedValue(planWithChanges)
let callOrder = 0
mockApply.mockImplementation(async () => {
callOrder++
return callOrder === 1
? { network: 'dev_net' } // level 1 output
: { db: 'localhost:5432' } // level 2 output
})
await executor.exec(opts)
expect(mockApply).toHaveBeenCalledTimes(2)
expect(ctx.findOutput('network', 'network')).toBe('dev_net')
})
```
## E2E Tests for Provider-Dependent Logic
When new functionality processes or depends on real provider output (e.g., Terraform plan JSON, Pulumi preview JSON), **always add e2e tests** (`*.e2e.test.ts`) that:
1. Parse raw CLI output from fixture strings through the real provider parser
2. Feed the resulting typed structures (e.g., `ResourceChange[]`, `ProviderPlan`) into the new function
3. Assert correct behavior against **both** provider formats (they differ structurally — e.g., Pulumi uses `step.oldState.inputs`/`step.newState.inputs`, Terraform uses inline `before`/`after`)
This catches integration issues that unit tests with hand-crafted objects miss (unexpected field shapes, nested structures, null patterns).
```ts
// provider-fixtures.ts — add new fixtures for each scenario
export const PROVIDER_OUTPUT_METADATA_ONLY = [
'{"type":"planned_change","change":{"action":"update","before":{"x":"same"},"after":{"x":"same"}}}',
'{"type":"change_summary","changes":{"add":0,"change":1,"remove":0}}',
].join('\n')
// my-feature.e2e.test.ts
import { parseProviderOutput } from '../providers/provider.js'
import { myNewFunction } from './my-feature.js'
import { PROVIDER_OUTPUT_REAL_CHANGE, PROVIDER_OUTPUT_METADATA_ONLY } from '../__test-utils__/provider-fixtures.js'
it('should work with real provider output', () => {
const plan = parseProviderOutput(PROVIDER_OUTPUT_REAL_CHANGE, 'proj')
const result = myNewFunction(plan.resourceChanges)
expect(result.count).toBe(1)
})
```
## Gotchas
- **Zod schema coercion vs raw YAML:** If code validates with `schema.safeParse(raw)` but then uses `raw as Type` instead of `parsed.data`, the coercion transforms (e.g., `z.coerce.string()`) are NOT applied. Test against actual behavior, not schema definition.
- **Module-level singletons (mutex, registry):** These persist across tests within a file. Concurrent tests that lock a mutex may deadlock. Prefer sequential operations in tests or mock the singleton.
- **`vi.mock` hoisting:** `vi.mock()` calls are hoisted to the top of the file. Variables referenced inside the factory must be declared with `vi.fn()` at module scope — they can't reference `let` variables from `beforeEach`.
- **Fixture accuracy:** Always verify test fixtures match the real CLI output format — don't trust existing fixtures blindly. Simplified/outdated fixtures can mask parser bugs (e.g., a fixture using `step.resource.properties` when the real CLI outputs `step.oldState.inputs`).
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!