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
- Vitest's workspace feature lets you define per-package test environments from a single root config.
- Moon's affected detection runs only the Vitest suites touched by a change, not the entire workspace.
- Coverage can be merged across all packages using Vitest's --merge-reports flag.
- Watch mode works at the package level through Moon's passthrough arguments.
- 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:
- Speed at the individual test level (Vitest's HMR-based test runner)
- 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.jsonWorkspace-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: trueThe 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: jsdomRunning 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:watchPass 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.tsVitest 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=v8Configure 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.jsonTesting 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: trueRunning 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 --affectedFor packages with slow test suites, use Vitest's --reporter=dot for minimal output:
moon run utils:test -- --reporter=dotBrowser 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.