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/expectbasics are nearly the same) - The
ng testcommand 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.
Remove browser-related CI steps
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:
toContaindeep-compares elements - Vitest:
toContainuses 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/.skipleft 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+) keepsng testbut 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 (
createSpyObj→vi.fn(),.and.returnValue→.mockReturnValue) - For arrays of objects, Jasmine
toContainis deep / Vitest is reference—usetoContainEqualfor value comparison - For Ionic, put
@ionic/angular,@ionic/core, andioniconsinserver.deps.inline; add deps that import Ionic internally as they appear - Minimal polyfills in
test-setup.tsfor missing DOM APIs;fake-indexeddb/autofor IndexedDB - In monorepos, regenerate lockfile with
rm -rf node_modules package-lock.json && npm installto 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.