← All articles

Migrating Ionic + Angular Unit Tests from Karma/Jasmine to Vitest (jsdom)

Move Ionic + Angular unit tests to Angular's official @angular/build:unit-test builder with Vitest and jsdom—setup steps, Ionic-specific pitfalls, and CI lockfile traps.

Published
Migrating Ionic + Angular Unit Tests from Karma/Jasmine to Vitest (jsdom) cover image

For a long time, Karma + Jasmine was the standard for Angular unit tests. Karma is "headless," but it still launches a real Chrome browser, which means CI needs Chrome installed and CHROME_BIN set, and startup adds latency—it is quietly high maintenance. Karma itself is already deprecated.

Another issue: needing a real browser means tests cannot run at all in environments without one. I sometimes write code in Devin's cloud environment, which has no Chrome, so with Karma I could not run tests there. I had to push and wait for GitHub Actions. Making a remote CI round trip just to see if tests pass killed my rhythm, and I really disliked that.

From Angular v20 onward, the official @angular/build:unit-test builder is available. It uses Vitest as the runner and, by default, jsdom to run tests on Node. In v21, Vitest becomes the default (peer vitest@^4). The ng test command stays the same; only the runtime moves from a real browser to Node.

I did this migration on an Ionic + Angular app, so here are the steps and Ionic-specific sticking points. I write this as general guidance that does not depend on a particular library layout—hopefully a useful foundation for anyone moving ng test to Vitest.

What changes

Roughly speaking, only the test runtime is swapped; most test code stays as-is.

  • Runner: Karma → Vitest
  • Runtime: real browser (ChromeHeadless) → jsdom (DOM on Node)
  • Assertions: Jasmine → Vitest (describe / it / expect basics are nearly the same)
  • The ng test command itself stays

Angular's standard TestBed style does not change. What changes is rewriting Jasmine-specific APIs (jasmine.createSpyObj, etc.), handling browser APIs jsdom lacks, and CI plumbing. If I cover those, the migration is largely mechanical.

As a prerequisite, @angular/build must be v20 or later.

node -e "console.log(require('@angular/build/package.json').version)"

Migration steps

Replace the angular.json test target

Replace the test target in angular.json from the Karma builder to @angular/build:unit-test.

// angular.json
"test": {
  "builder": "@angular/build:unit-test",
  "options": {
    "tsConfig": "tsconfig.spec.json",
    "buildTarget": "<project>:build",
    "runner": "vitest",
    "runnerConfig": "vitest.config.ts", // Only when jsdom configuration is needed (see below)
    "coverage": false
  },
  "configurations": { "ci": { "watch": false } }
}

Remove old Karma options (main: src/test.ts, polyfills, karmaConfig, inlineStyleLanguage, coverage-related fields). The default include is **/*.spec.ts; if you did not override it, leave it as-is.

Swap types in tsconfig.spec.json

Change type definitions from Jasmine to Vitest.

// tsconfig.spec.json
{
  "compilerOptions": {
    "types": ["vitest/globals"]  // Changed from ["jasmine"]
  },
  "include": ["src/**/*.spec.ts"]
}

Remove files entries pointing at src/test.ts or polyfills from the old setup. Keep include as src/**/*.spec.ts.

Remove files you no longer need

Delete karma.conf.js and src/test.ts. Bootstrap like getTestBed().initTestEnvironment(...) in src/test.ts is handled by the new builder. Keep src/polyfills.ts if the build still uses it.

Swap dependencies in package.json

Remove Karma/Jasmine devDependencies and add Vitest and jsdom.

  • Remove: karma, karma-*, jasmine-core, jasmine-spec-reporter, @types/jasmine
  • Add: vitest@^4, jsdom

Adding "test:ci": "ng test --watch=false" for CI is handy. Keep "test": "ng test" as-is.

Run tests with ng test --watch=false (or npm run test -- --watch=false). jsdom needs no real browser. Drop everything you added for Karma: --browsers=ChromeHeadlessCI, --no-watch, steps that hunt for CHROME_BIN, Chrome installation, and so on. That is one of the nice parts of the migration—CI definitions get simpler (E2E with Playwright and similar stays separate).

Jasmine → Vitest rewrites

Most of this is mechanical replacement, with a few traps.

Globals (describe / it / expect) and matchers like toBe, toBeTruthy, toBeFalsy, toBeDefined, and toContain mean the same and usually pass unchanged. fakeAsync / tick come from Angular and do not depend on the runner—leave them as-is.

Rewrites are mainly around spies.

// Jasmine
const svc = jasmine.createSpyObj('MyService', ['load', 'save']);
svc.load.and.returnValue(of(data));
spyOn(obj, 'method').and.callThrough();

// Vitest
const svc = {
  load: vi.fn().mockReturnValue(of(data)),
  save: vi.fn(),
};
vi.spyOn(obj, 'method'); // callThrough-like behavior is the default

Replace jasmine.createSpyObj(name, methods) with an object whose methods are vi.fn(). .and.returnValue(x) becomes .mockReturnValue(x); spyOn(obj, 'm') becomes vi.spyOn(obj, 'm'). .and.callThrough() is unnecessary because that is Vitest's default for vi.spyOn.

The trap where toContain changes meaning

One thing to watch: toContain. For substring checks on strings, both behave the same. For arrays containing objects, they differ.

  • Jasmine: toContain deep-compares elements
  • Vitest: toContain uses reference equality

So a test that checks "the array contains an object with equal values" may fail after migration because references differ. Use toContainEqual for value comparison. This does not fail at compile time—it fails quietly at runtime—so fix each case as you find it during migration.

While you are at it, delete dead Jasmine-era code like unused spy variables for clearer tests.

Where jsdom gets stuck

From here, Ionic + Angular specifics matter. jsdom is a DOM on Node, so some browser APIs are missing. Add vitest.config.ts only when needed and point runnerConfig in angular.json at it.

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    setupFiles: ['src/test-setup.ts'],
    server: {
      deps: {
        inline: [/@ionic\/angular/, /@ionic\/core/, /ionicons/],
      },
    },
  },
});

Directory import ... is not supported resolving ES modules

With Ionic, this error shows up first.

Error: Directory import '.../@ionic/core/components' is not supported resolving ES modules

Externalized Ionic packages (not bundled, treated as external deps) cannot resolve Node ESM directory imports. Node ESM does not resolve import '.../components'-style directory paths, so I inline these packages in Vitest—that is server.deps.inline. List @ionic/angular, @ionic/core, and ionicons as regexes.

There is a pattern that bites again if you relax too soon. When you add a spec that imports a third-party library wrapping Ionic internally, the same directory import can reappear via that package. Find the culprit by searching node_modules for packages importing Ionic.

grep -rl "@ionic/angular" node_modules/<package-name>

Add the package to server.deps.inline. Assume any dependency that uses Ionic internally may need inlining—then later surprises hurt less.

Minimal polyfills for DOM APIs jsdom lacks

Next, apps or libraries may call DOM APIs jsdom does not implement.

TypeError: Element.prototype.scrollTo is not a function

APIs tied to layout or scrolling like scrollTo may be absent in jsdom. If the call is unrelated to what the test asserts and only causes an unhandled error, silence it with a minimal polyfill in src/test-setup.ts.

// src/test-setup.ts
Element.prototype.scrollTo = () => {};

Before no-op stubbing, check that the call is not hiding meaningful assertions. If it is only a side effect, an empty implementation is fine. If the test verifies scroll position itself, consider browser mode below.

Firebase and indexedDB is not defined

Libraries like Firebase touch IndexedDB at init; jsdom has no IndexedDB. fake-indexeddb fixes this.

// src/test-setup.ts
import 'fake-indexeddb/auto';

Add fake-indexeddb as a devDependency and import it at the top of the setup file.

When layout fidelity really matters

If you test layout, CSS, or scroll behavior—as in the scrollTo example—jsdom cannot pass in principle. Switch only those tests to Vitest browser mode (@vitest/browser + Playwright for a real browser). That returns to a real browser, so limit it to tests that need it. Most unit tests are fine on jsdom.

CI and lockfile traps (especially in monorepos)

Last, a trap monorepos often hit once. Swapping devDependencies changes package-lock.json, and sometimes CI fails while local passes.

npm error `npm ci` can only install packages when your package.json and package-lock.json are in sync.
npm error Missing: glob-parent@... from lock file
npm error Missing: readdirp@... from lock file

With node_modules at the monorepo root, npm ci in a subdirectory can pick up hoisted deps and pass even with an incomplete lockfile. CI often handles only the subdirectory without a root install, so gaps show up there. Vitest/Chokidar transitive deps (glob-parent, readdirp, picomatch, etc.) trigger this often.

The fix is a clean lockfile rebuild.

rm -rf node_modules package-lock.json && npm install

Partial npm install on existing node_modules can leave an incomplete lock, so remove and reinstall. To reproduce CI locally, temporarily move the root node_modules aside and run npm ci.

mv ../node_modules ../node_modules._bak && npm ci && mv ../node_modules._bak ../node_modules

Note: npm ci --dry-run looks at existing node_modules and can be lenient—passing there is not proof CI will pass.

Verification: not just green, but the same coverage

After migration, do not stop at "everything green." Check that no tests were dropped accidentally.

  • Total it( count matches the number of tests actually executed
  • No stray fit / xit / .only / .skip left from debugging

Then confirm all specs green, npm run lint (no type errors from removing @types/jasmine), and npm run build. "Passed" and "passed with the same coverage as before" are different—compare test counts once for peace of mind.

Summary

Key points for moving Ionic + Angular unit tests from Karma/Jasmine to Vitest (jsdom):

  • @angular/build:unit-test (v20+) keeps ng test but moves from a real browser to jsdom; Chrome-related CI goes away and things get lighter
  • No real browser means tests run on the spot in Chrome-less environments (Devin's cloud, CI containers)
  • Most test code stays; rewrites are mainly spies (createSpyObjvi.fn(), .and.returnValue.mockReturnValue)
  • For arrays of objects, Jasmine toContain is deep / Vitest is reference—use toContainEqual for value comparison
  • For Ionic, put @ionic/angular, @ionic/core, and ionicons in server.deps.inline; add deps that import Ionic internally as they appear
  • Minimal polyfills in test-setup.ts for missing DOM APIs; fake-indexeddb/auto for IndexedDB
  • In monorepos, regenerate lockfile with rm -rf node_modules package-lock.json && npm install to avoid CI-only failures
  • After migration, match it( count to executed tests and confirm zero misses

Skipping a real browser makes tests faster and CI simpler. Personally, the best change was running ng test as-is in browser-less places like Devin's cloud—I no longer push just to confirm tests. If I cover Ionic inlining and jsdom polyfills upfront, the migration itself is fairly straightforward. I hope this helps others doing the same move.

See you next time.