Fixing Vitest CI Worker Crashes by Switching from jsdom to happy-dom
Our Vitest suite passed every time locally, but GitHub Actions failed before executing a single test. Instead of failed assertions, every test worker crashed during startup, leaving us with 0% coverage and a broken CI pipeline. After narrowing the issue down to our test environment, we replaced jsdom with happy-dom . That change resolved the worker crashes in our environment and restored a stable GitHub Actions pipeline. Environment Context To help determine if this applies to your setup, here is the stack where we encountered this: - Framework: Next.js (App Router) - Testing: Vitest, @testing-library/react - Package Manager: pnpm (Workspace / Monorepo) - CI Environment: GitHub Actions ( ubuntu-latest , Node.js 20) 1. The Error: Unhandled Worker Failures in CI During the pnpm --filter app test:coverage step in GitHub Actions, Vitest failed immediately upon initializing the worker pool: Vitest caught 7 unhandled errors during the test run. โฏโฏโฏโฏโฏโฏ Unhandled Error โฏโฏโฏโฏโฏโฏโฏ Error: [vitest-pool]: Failed to start forks worker for test files /apps/app/tests/poll.test.ts. Caused by: TypeError: webidl.util.markAsUncloneable is not a function โฏ new CacheStorage ../node_modules/jsdom/node_modules/undici/lib/web/cache/cachestorage.js:20:17 โฏ Object. ../node_modules/jsdom/node_modules/undici/index.js:179:25 โฏ Object. ../node_modules/jsdom/lib/api.js:12:33 Test Files no tests Tests no tests Errors 7 errors ERROR: Coverage for lines (0%) does not meet global threshold (80%) If you encounter errors such as webidl.util.markAsUncloneable is not a function or Failed to start forks worker in a vitest-pool setup, the problem may lie in your test environment's dependency tree rather than in your test code. 2. The Investigation At first, we assumed one of the test files was failing or throwing an uncaught exception. But the stack trace told a different story. None of the 7 test files had actually started executing. Every failure occurred while Vitest was initializing worker processes, which meant the problem was happening before our application code even loaded. The stack trace pointed directly into jsdom 's internal undici dependency during worker initialization (jsdom/node_modules/undici/lib/web/cache/cachestorage.js ). Rather than a bug in our application code, this suggested an environment-level compatibility issue involving the test environment rather than our application code. Beyond initial loading issues, jsdom emulates a large portion of standard browser behavior and maintains a detailed DOM object model. In resource-constrained CI environments (like standard Linux runners in GitHub Actions), this extra memory footprint can increase resource usage and startup time, especially during coverage runs. We could have spent more time investigating the exact dependency mismatch, but since our tests only required standard DOM APIs, switching to a lighter implementation was the simpler and lower-risk solution. 3. The Switch: Moving to happy-dom Why happy-dom Worked Unlike jsdom , happy-dom focuses specifically on implementing the browser APIs commonly needed by modern frontend unit tests (such as React synthetic events and DOM queries) rather than reproducing every full browser behavior. Because of its smaller scope, happy-dom generally starts faster and has a lower resource footprint than jsdom for many component-testing scenarios, making it a good fit for React component testing in Vitest. Step 1: Replace Dependencies pnpm --filter app remove jsdom pnpm --filter app add -D happy-dom @vitest/coverage-v8 Step 2: Update Vitest Configuration Update vitest.config.ts to use happy-dom : import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; import path from 'path'; export default defineConfig({ plugins: [react()], resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, test: { environment: 'happy-dom', globals: true, setupFiles: ['./vitest.setup.ts'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html', 'json-summary'], exclude: [ 'node_modules/', '.next/', 'vitest.config.ts', 'vitest.setup.ts', '**/*.d.ts', ], }, }, }); Step 3: Recommended Test Setup In vitest.setup.ts , ensure component trees are explicitly unmounted after each test block: import '@testing-library/jest-dom/vitest'; import { cleanup } from '@testing-library/react'; import { afterEach } from 'vitest'; afterEach(() => { cleanup(); }); Note: This cleanup setup was not required to fix the worker initialization issue, but we keep it to ensure each test runs with a clean DOM and to avoid state leaking between tests. 4. Results | Metric | Before (jsdom ) | After (happy-dom ) | |---|---|---| | Worker Initialization | โ Failed across all 7 test files | โ All workers started successfully | | Code Coverage | โ 0% (Threshold failure) | โ Coverage generated successfully | | Pipeline Status | โ CI failed consistently | โ CI passed consistently | | Execution | โ Failed during worker initialization | โก Entire suite finished in ~15 seconds | While we didn't identify the exact package combination that triggered the incompatibility, replacing jsdom removed the issue entirely for our CI environment, making further investigation unnecessary for our use case. 5. When Should You Keep jsdom ? Every tool comes with trade-offs, and happy-dom isn't a 1:1 replacement for every project: - Keep jsdom if: Your tests depend on advanced browser APIs thathappy-dom doesn't fully implement, or you are testing behavior that closely mirrors browser layout and parsing internals. - Use happy-dom if: You are primarily running React, Vue, or Svelte component tests with@testing-library , where rendering components and asserting on DOM output is all you need. 6. Lessons Learned & Takeaways Key Takeaway: Before changing test code, check whether the failure occurs before the first test executes. If worker initialization fails, the root cause is often the test environment, dependency graph, or runtime-not your application logic. The lesson wasn't that happy-dom is universally better than jsdom . It was that your test environment should match your testing needs. For our React component tests, a lightweight DOM implementation was enough-and choosing the simplest test environment that satisfied our requirements made the CI pipeline both faster and more reliable. Sometimes the quickest path to a stable CI pipeline isn't finding the perfect root cause-it's choosing the simplest tool that satisfies your testing requirements. Top comments (0)
Comments
No comments yet. Start the discussion.