Testing Standards

Overview

Testing patterns and conventions using Vitest. Tests live alongside source files with the .test.ts extension. These rules cover file organization, mocking strategies, and coverage expectations.

Rules

File Structure and Naming

Place test files next to the source they test. Use .test.ts as the extension. Name describe blocks after the module or function under test, and write test cases as "should + expected behavior."

ElementConventionExample
Test file*.test.tsconfig.test.ts
Describe blockFeature/function namedescribe('loadConfig', ...)
Test caseShould + expected behaviorit('should return resolved config', ...)

Correct

src/
├── lib/
│   ├── config.ts
│   └── config.test.ts
├── runtime/
│   ├── executor.ts
│   └── executor.test.ts
test/
└── integration/
    └── auth-oauth-pkce.test.ts

Write Clear Test Cases

Each test should have a single assertion focus. Use async/await for asynchronous code and toMatchObject for partial matching.

Correct

import { describe, it, expect } from 'vitest'
import { resolveScriptPath } from './resolver'

describe('resolveScriptPath', () => {
  it('should resolve path relative to workspace root', () => {
    const result = resolveScriptPath('build', '/project')
    expect(result).toBe('/project/scripts/build.ts')
  })

  it('should return undefined for missing scripts', () => {
    expect(resolveScriptPath('missing', '/project')).toBeUndefined()
  })
})

// Async tests
it('should load config from parent directories', async () => {
  const config = await loadConfig('/project/packages/maltty')
  expect(config).toMatchObject({
    name: expect.any(String),
  })
})

Mock External Dependencies

Use vi.mock for module-level mocks and vi.fn for individual functions. Replace real I/O (file system, network) with deterministic mocks.

Correct

import { vi, describe, it, expect } from 'vitest'

// Mock a module
vi.mock('node:fs/promises', () => ({
  readFile: vi.fn().mockResolvedValue('{ "name": "test" }'),
  stat: vi.fn().mockResolvedValue({ isFile: () => true }),
}))

// Mock individual functions
const mockCallback = vi.fn()
mockCallback.mockReturnValue('result')
mockCallback.mockResolvedValue('async result')

// Assert calls
expect(mockCallback).toHaveBeenCalledWith('arg')
expect(mockCallback).toHaveBeenCalledTimes(1)

Exception: per-test module factories

Use vi.doMock when the code under test loads a module through a dynamic await import() and individual tests need different factories for the same specifier. vi.mock hoists to the top of the file and applies one factory to every test, so those tests would share the first fixture registered.

autoload() in packages/maltty is the case this covers -- each test declares a different fake command module for the same path.

it('should load the command', async () => {
  vi.doMock('/tmp/commands/deploy.ts', () => ({ default: someCommand }))

  const result = await autoload({ dir: '/tmp/commands' })

  expect(result['deploy']).toBeDefined()
})

vi.clearAllMocks() and vi.restoreAllMocks() reset call history and spies but leave the registered factory in place, and vi.resetModules() only drops the module cache. To retire a factory, call vi.doUnmock(specifier) for each mocked path and then vi.resetModules() before the next dynamic import -- otherwise a later test reuses the previous test's module.

Organize Tests by Feature

Group related tests with nested describe blocks. Use beforeEach to reset mocks and shared state before each test.

Correct

import { beforeEach, describe, it, vi } from 'vitest'

describe('ScriptRunner', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  describe('execute', () => {
    it('should run the script command', () => {})
    it('should pass environment variables', () => {})
  })

  describe('resolve', () => {
    it('should find scripts in workspace root', () => {})
    it('should return error for missing scripts', () => {})
  })
})

Meet Coverage Requirements

Target the following minimum coverage levels by area.

AreaMinimum Coverage
Critical path (config)100%
Business logic80%
Utilities70%

Test Edge Cases and Error Handling

Test pure functions exhaustively, including boundary values and error paths.

Correct

describe('parseTimeout', () => {
  it('should parse valid number', () => {
    expect(parseTimeout('5000')).toBe(5000)
  })

  it('should return default for NaN', () => {
    expect(parseTimeout('abc')).toBe(30000)
  })

  it('should clamp negative values to zero', () => {
    expect(parseTimeout('-1')).toBe(0)
  })
})

it('should return error result for missing config', async () => {
  vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'))

  const [error] = await loadConfig('/missing')
  expect(error).toMatchObject({ message: expect.stringContaining('ENOENT') })
})

Avoid Testing Anti-patterns

Don'tDo Instead
Test implementation detailsTest behavior/outcomes
Large test filesSplit by feature
Shared mutable stateReset in beforeEach
Skip tests without reasonDelete or fix
Test framework codeTrust dependencies

Resources

References

  • Functions -- Pure functions and testability
  • State -- Immutable state patterns