Moon.build with Vitest: Workspace-Level Testing Strategies

Moon.build with Vitest: Workspace-Level Testing Strategies

Combining Moon.build with Vitest gives you fast, isolated test runs per package with smart caching, plus the option of a unified workspace-level test runner for coverage merging. Moon handles the orchestration; Vitest handles the execution.

Key Takeaways

  1. Vitest's workspace feature lets you define per-package test environments from a single root config.
  2. Moon's affected detection runs only the Vitest suites touched by a change, not the entire workspace.
  3. Coverage can be merged across all packages using Vitest's --merge-reports flag.
  4. Watch mode works at the package level through Moon's passthrough arguments.
  5. Shared Vitest configuration lives in the workspace root and is extended per package.

Why Vitest and Moon Work Well Together

Vitest is fast by design — it uses Vite's transformation pipeline and runs tests in parallel worker threads. Moon adds the outer orchestration layer: which packages run, in what order, and when to use cached results. Together they cover the two main problems in monorepo testing:

  1. Speed at the individual test level (Vitest's HMR-based test runner)
  2. Speed at the orchestration level (Moon's dependency graph and caching)

Neither tool tries to do the other's job. Vitest does not know about your monorepo structure. Moon does not know how to run JavaScript tests. The combination is more capable than either alone.

Project Structure

A typical monorepo using Moon and Vitest looks like this:

.
├── .moon/
│   ├── workspace.yml
│   ├── toolchain.yml
│   └── tasks.yml           # inherited task defaults
├── packages/
│   ├── utils/
│   │   ├── moon.yml
│   │   ├── vitest.config.ts
│   │   └── src/
│   ├── ui-components/
│   │   ├── moon.yml
│   │   ├── vitest.config.ts
│   │   └── src/
│   └── api-client/
│       ├── moon.yml
│       ├── vitest.config.ts
│       └── src/
├── apps/
│   └── web/
│       ├── moon.yml
│       ├── vitest.config.ts
│       └── src/
├── vitest.workspace.ts     # optional: unified workspace runner
└── package.json

Workspace-Level Moon Configuration

Define the default test task in .moon/tasks.yml so every project inherits it:

# .moon/tasks.yml
tasks:
  test:
    command: vitest run
    inputs:
      - '@globs(sources)'
      - '@globs(tests)'
      - 'vitest.config.ts'
      - 'vitest.config.mts'
      - '*.config.ts'
    outputs:
      - coverage
      - test-results
    options:
      cache: true
      runInCI: true
      outputStyle: stream

  test:watch:
    command: vitest watch
    local: true
    inputs:
      - '@globs(sources)'
      - '@globs(tests)'
    options:
      cache: false
      persistent: true

The local: true flag tells Moon that test:watch should never run in CI, only locally.

Per-Package Vitest Configuration

Each package has its own vitest.config.ts. The key is to keep them minimal by sharing a root config:

// vitest.workspace.root.ts (shared base config)
import { defineConfig } from 'vitest/config'

export const baseConfig = defineConfig({
  test: {
    globals: true,
    environment: 'node',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      reportsDirectory: 'coverage',
    },
    reporters: [
      'default',
      ['junit', { outputFile: 'test-results/junit.xml' }],
    ],
  },
})
// packages/utils/vitest.config.ts
import { mergeConfig } from 'vitest/config'
import { baseConfig } from '../../vitest.workspace.root.ts'

export default mergeConfig(baseConfig, {
  test: {
    name: 'utils',
    include: ['src/**/*.test.ts', 'tests/**/*.test.ts'],
  },
})
// packages/ui-components/vitest.config.ts
import { mergeConfig } from 'vitest/config'
import { baseConfig } from '../../vitest.workspace.root.ts'

export default mergeConfig(baseConfig, {
  test: {
    name: 'ui-components',
    environment: 'jsdom',  // override for DOM tests
    include: ['src/**/*.test.tsx', 'tests/**/*.test.tsx'],
    setupFiles: ['./tests/setup.ts'],
  },
})

Per-Package moon.yml

Most packages can rely entirely on the inherited task from .moon/tasks.yml. Only override what differs:

# packages/utils/moon.yml
language: typescript
type: library

# No tasks section needed — inherits from .moon/tasks.yml
# apps/web/moon.yml
language: typescript
type: application

dependsOn:
  - utils
  - ui-components
  - api-client

tasks:
  test:
    # Override to add coverage threshold for the main app
    args: '--coverage --coverage.thresholds.lines=80'
    inputs:
      - src/**/*
      - tests/**/*
      - vitest.config.ts
    outputs:
      - coverage
# packages/ui-components/moon.yml
language: typescript
type: library

tasks:
  test:
    # jsdom environment requires a different setup
    env:
      VITEST_ENV: jsdom

Running Tests with Moon

Run tests across all packages:

# Run all tests
moon run :<span class="hljs-built_in">test

<span class="hljs-comment"># Run tests only for affected packages
moon run :<span class="hljs-built_in">test --affected --base main

<span class="hljs-comment"># Run tests for a specific package
moon run utils:<span class="hljs-built_in">test

<span class="hljs-comment"># Run tests with watch mode (local only)
moon run utils:<span class="hljs-built_in">test:watch

Pass arguments through to Vitest:

# Run only tests matching a pattern
moon run utils:<span class="hljs-built_in">test -- --reporter=verbose

<span class="hljs-comment"># Run with a specific test file
moon run utils:<span class="hljs-built_in">test -- src/format.test.ts

Vitest Workspace Mode (Alternative Approach)

Vitest has its own workspace concept that can run tests across all packages from a single process. This is separate from Moon's orchestration and useful for development:

// vitest.workspace.ts (root)
import { defineWorkspace } from 'vitest/config'

export default defineWorkspace([
  'packages/*/vitest.config.ts',
  'apps/*/vitest.config.ts',
])

With this file in place, running vitest from the root runs all packages in a single process, sharing Vite's module graph across them. This is faster for interactive development but bypasses Moon's caching.

The two modes serve different purposes:

Mode Use Case Caching Parallelism
moon run :test CI, affected-only runs Yes (Moon cache) Per dependency level
vitest --workspace Local development No All packages at once
moon run utils:test:watch Single-package dev No Single package

Coverage Merging

When Moon runs tests per-package, each package produces its own coverage report. To get a unified coverage view across the monorepo, merge them:

# Run tests with coverage across all packages
moon run :<span class="hljs-built_in">test

<span class="hljs-comment"># Merge coverage reports
vitest --merge-reports --coverage.provider=v8

Configure the merge in a root-level script:

{
  "scripts": {
    "coverage:merge": "vitest --merge-reports=.vitest-reports --coverage --coverage.reportsDirectory=coverage/merged"
  }
}

Each package's test task outputs coverage data to a vitest-reports/ directory:

// shared base config — update the coverage reporter
export const baseConfig = defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['json'],
      reportsDirectory: '.vitest-reports',
    },
  },
})

Then in your CI pipeline:

- name: Run affected tests with coverage
  run: moon run :test --affected

- name: Merge coverage reports
  run: pnpm vitest --merge-reports --coverage

- name: Upload coverage
  uses: codecov/codecov-action@v4
  with:
    files: coverage/merged/coverage-final.json

Testing Different Environments per Package

Vitest supports multiple test environments in a workspace. Some packages need Node.js (API clients, utilities), others need jsdom (React components), others need a custom environment (Web Workers, Cloudflare Workers):

// packages/worker/vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    name: 'worker',
    environment: 'miniflare',  // Cloudflare Workers environment
    environmentOptions: {
      workers: {
        modulesRules: [{ type: 'ESModule', include: ['**/*'] }],
      },
    },
  },
})

Moon is unaware of these environment differences — it just runs vitest run in each package directory. The per-package Vitest config handles the environment selection. This separation of concerns keeps both configurations clean.

Snapshot Testing Across Packages

When multiple packages use Vitest snapshots, Moon's caching requires careful setup. Snapshots are outputs — if they change, the cache must be invalidated:

# packages/ui-components/moon.yml
tasks:
  test:
    inputs:
      - src/**/*
      - tests/**/*
      - '**/__snapshots__/**'   # include snapshots in inputs
    outputs:
      - coverage
      - '**/__snapshots__/**'   # and in outputs (Moon restores them on cache hit)

When a snapshot changes (intentionally, via vitest --update-snapshots), Moon detects the input change and invalidates the cache for downstream packages that depend on the updated component.

Type-Checking as a Separate Task

Vitest runs tests but does not enforce TypeScript types — tsc --noEmit or vue-tsc does. Keep these as separate Moon tasks:

# .moon/tasks.yml
tasks:
  typecheck:
    command: tsc --noEmit --project tsconfig.json
    inputs:
      - src/**/*
      - tsconfig.json
      - '../../tsconfig.base.json'
    options:
      cache: true

  test:
    command: vitest run
    inputs:
      - src/**/*
      - tests/**/*
      - vitest.config.ts
    deps:
      - typecheck   # ensure types pass before running tests
    options:
      cache: true

Running moon run :test will now typecheck each package before running its tests, in dependency order.

Local Development Workflow

The recommended local workflow when using Moon + Vitest:

# Start watch mode for a specific package you're working on
moon run utils:<span class="hljs-built_in">test:watch

<span class="hljs-comment"># In another terminal, run affected tests to see the full impact
moon run :<span class="hljs-built_in">test --affected

For packages with slow test suites, use Vitest's --reporter=dot for minimal output:

moon run utils:test -- --reporter=dot

Browser and E2E Testing

Vitest runs unit and integration tests, but it cannot test the real browser behavior of your deployed applications. For that layer, HelpMeTest lets your team write end-to-end browser tests in plain English — no Vitest config, no Playwright setup. It complements the Moon + Vitest stack by covering user journeys that unit tests cannot reach: authentication flows, multi-page interactions, and visual regressions across your deployed app.

Summary

Moon and Vitest are a natural fit: Vitest is fast and flexible at the test-execution level; Moon is precise and cache-aware at the orchestration level. Use inherited task defaults in .moon/tasks.yml to keep per-package moon.yml files minimal. Use Vitest's workspace config for local interactive development, and Moon's --affected flag for CI. Merge coverage reports at the end of each CI run to maintain a unified view of test coverage across the entire monorepo.

Read more