Skip to content
cccskills
FreePublished on GitHub

vitest-test-patterns

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.

The English summary is being prepared. Showing the original description.

Works withTerraform
See how to install

Files included (1)

  • SKILL.md7.8 KB

SKILL.md (original)

Review the instructions the agent will receive before you install.

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:

// __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:

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:

// 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:

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:

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:

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:

// 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:

// __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
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).

// 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).

Reviews

No reviews yet. Tell others what you thought.

Comments

No login needed. Share how it worked for you, ask a question, or note where it did not run.

No comments yet. Be the first to write one.

Comments are published right away. Do not include personal information such as email addresses or phone numbers. Up to 2 URLs.

0 / 1,000 characters

This form is protected by reCAPTCHA, and the Google Privacy Policy and Terms of Service apply.

More skills from this repository

Add --json flag to CLI commands for machine-readable output on stdout. Generic pattern for any CLI tool, enabling piping to jq and CI/CD integration while keeping human-readable output on stderr.

The English summary is being prepared. Showing the original description.

krafteq/infraglue2Updated Jul 8, 2026

Add shell completion support via a 'completion' subcommand for bash, zsh, and fish. Generic pattern for any Commander.js CLI, with static and dynamic completion examples.

The English summary is being prepared. Showing the original description.

krafteq/infraglue2Updated Jul 8, 2026

Add zod schema validation for CLI options and YAML/JSON config files. Generic pattern for runtime validation with type-safe parsing and clear error messages in any Commander.js CLI.

The English summary is being prepared. Showing the original description.

krafteq/infraglue2Updated Jul 8, 2026

Set up @changesets/cli for semantic versioning, CHANGELOG generation, and npm publishing in a monorepo or single package. Covers init, config, contributor workflow, GitHub Actions release automation, and npm provenance.

The English summary is being prepared. Showing the original description.

krafteq/infraglue2Updated Jul 8, 2026

Add automatic CI/CD environment detection using ci-info package. Generic pattern for any CLI tool that needs to adapt behavior (prompts, output, integrations) based on whether it's running in CI.

The English summary is being prepared. Showing the original description.

krafteq/infraglue2Updated Jul 8, 2026

Polish CLI user experience with @clack/prompts for interactive flows, update-notifier for version awareness, and enhanced Commander.js help text with examples and colors. Generic patterns for any Node.js CLI.

The English summary is being prepared. Showing the original description.

krafteq/infraglue2Updated Jul 8, 2026

See all skills by krafteq

Report a problem with this skill