+1 (726) 227-3745

Karma Is Gone: Migrating Angular Unit Tests to Vitest

Angular's Karma-based test runner has run its course. Karma was deprecated in Angular 18, the @angular-devkit/build-angular:karma builder was removed from new workspaces, and Vitest is now the runner the CLI scaffolds and the one we standardise on for client-side unit tests in every MEAN engagement. If you have inherited an Angular app with karma.conf.js, test.ts and a ChromeHeadless CI job, this tutorial walks the whole migration: switching the builder, fixing the specs that break, testing signals and HttpClient, and getting coverage back in CI.

The examples assume Angular 22, Node.js 24 LTS and an Express 5/Mongoose 8 API behind the client. Nothing here touches the server, so the migration is safe to do on its own branch and merge independently.

What actually changes

Karma launched a real browser, bundled every spec into one giant test bundle, and reported back over a socket. Vitest runs your specs in Node against a simulated DOM (jsdom or happy-dom) or, optionally, in a real browser via Playwright. Practically:

Karma worldVitest world
karma.conf.js + test.tsno config files needed for the default setup
jasmine globals (describe, it, expect)Vitest globals, Jasmine-compatible expect for the common matchers
jasmine.createSpy(), spyOnvi.fn(), vi.spyOn
karma-coverage + istanbulvitest --coverage (V8 or Istanbul provider)
whole-bundle rebuild on changeper-file transform, HMR-speed watch mode

TestBed does not change. That is the important part: your TestBed.configureTestingModule, ComponentFixture, HttpTestingController and harness code all survive. What breaks is the surrounding scaffolding and any spec that leaned on Jasmine-only APIs.

Step 1: switch the builder

Install the runner and a DOM implementation:

npm install --save-dev vitest jsdom @vitest/coverage-v8

Then edit the test target in angular.json:

"test": {
  "builder": "@angular/build:unit-test",
  "options": {
    "tsConfig": "tsconfig.spec.json",
    "runner": "vitest",
    "browsers": [],
    "watch": false
  }
}

Two notes. First, the builder lives in @angular/build, so the project must already be on the modern application builder; if angular.json still says @angular-devkit/build-angular:browser, migrate that first with ng update @angular/cli and the use-application-builder schematic. Second, leaving browsers empty selects the jsdom environment. To run specs in a real Chromium instead, install @vitest/browser and playwright, then set "browsers": ["chromium"] — worth doing for components that measure layout or use ResizeObserver.

Now delete the Karma leftovers:

rm karma.conf.js src/test.ts
npm uninstall karma karma-chrome-launcher karma-coverage karma-jasmine karma-jasmine-html-reporter @types/jasmine jasmine-core

Trim tsconfig.spec.json so it no longer declares Jasmine types:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/spec",
    "types": ["vitest/globals", "node"]
  },
  "include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
}

Run ng test. Expect failures on the first pass — that is the point of the next two steps.

Step 2: fix the specs Karma was hiding

Four failure classes account for nearly everything we see in client codebases.

Jasmine spies. Replace mechanically:

// before
const svc = jasmine.createSpyObj('TasksApi', ['list', 'create']);
svc.list.and.returnValue(of([]));
spyOn(window, 'fetch').and.callThrough();

// after
import { vi } from 'vitest';

const svc = { list: vi.fn(() => of([])), create: vi.fn() };
vi.spyOn(window, 'fetch');

and.returnValue becomes mockReturnValue, and.callFake becomes mockImplementation, and.throwError becomes mockImplementation(() => { throw ... }). Loose matchers map too: jasmine.any(String) becomes expect.any(String), jasmine.objectContaining becomes expect.objectContaining.

Clocks and timers. jasmine.clock().install() becomes vi.useFakeTimers(), and tick(1000) from @angular/core/testing still works inside fakeAsync — prefer the Angular helpers when the code under test schedules through Angular, and vi.advanceTimersByTime for plain setTimeout logic. Always restore in afterEach(() => vi.useRealTimers()).

Globals that only exist in a browser. jsdom has no matchMedia, no IntersectionObserver and no real localStorage quota. Add a setup file rather than patching each spec. Point the builder at it with "setupFiles": ["src/test-setup.ts"] and write:

import { vi } from 'vitest';

Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: (query: string) => ({
    matches: false,
    media: query,
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  }),
});

class ObserverStub {
  observe = vi.fn();
  unobserve = vi.fn();
  disconnect = vi.fn();
}
vi.stubGlobal('IntersectionObserver', ObserverStub);
vi.stubGlobal('ResizeObserver', ObserverStub);

Cross-spec leakage. Karma's single bundle shared one global scope, so a spec that mutated localStorage or left a spy installed could quietly prop up the next one. Vitest isolates files, which surfaces the dependency as a failure. Fix the spec, do not fake the isolation: put the setup the test genuinely needs in its own beforeEach.

Step 3: a component spec, before and after

Here is a signals-based component from a real client dashboard:

@Component({
  selector: 'app-task-list',
  template: `
    <p data-testid="remaining">{{ remaining() }} remaining</p>
    @for (task of tasks(); track task._id) {
      <button (click)="toggle(task)">{{ task.title }}</button>
    } @empty {
      <p data-testid="empty">No tasks</p>
    }
  `,
})
export class TaskList {
  private api = inject(TasksApi);
  tasks = signal<Task[]>([]);
  remaining = computed(() => this.tasks().filter((t) => !t.done).length);

  async ngOnInit() { this.tasks.set(await this.api.list()); }
  async toggle(task: Task) {
    const updated = await this.api.setDone(task._id, !task.done);
    this.tasks.update((list) => list.map((t) => (t._id === updated._id ? updated : t)));
  }
}

The Vitest spec:

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { TaskList } from './task-list';
import { TasksApi } from './tasks-api';

describe('TaskList', () => {
  let fixture: ComponentFixture<TaskList>;
  const api = {
    list: vi.fn(),
    setDone: vi.fn(),
  };

  beforeEach(async () => {
    api.list.mockResolvedValue([
      { _id: 'a', title: 'Ship migration', done: false },
      { _id: 'b', title: 'Write tests', done: true },
    ]);
    await TestBed.configureTestingModule({
      imports: [TaskList],
      providers: [{ provide: TasksApi, useValue: api }],
    }).compileComponents();
    fixture = TestBed.createComponent(TaskList);
    await fixture.whenStable();
  });

  it('renders the outstanding count', () => {
    const el = fixture.nativeElement.querySelector('[data-testid="remaining"]');
    expect(el.textContent).toContain('1 remaining');
  });

  it('recomputes the count after a toggle', async () => {
    api.setDone.mockResolvedValue({ _id: 'a', title: 'Ship migration', done: true });
    fixture.nativeElement.querySelector('button').click();
    await fixture.whenStable();
    expect(api.setDone).toHaveBeenCalledWith('a', true);
    expect(fixture.nativeElement.querySelector('[data-testid="remaining"]').textContent)
      .toContain('0 remaining');
  });
});

Two habits worth adopting while you are in here. Use await fixture.whenStable() instead of fixture.detectChanges(): on a zoneless app, which is the Angular 22 default, whenStable is the honest way to wait for signal-driven rendering plus microtasks. And assert on data-testid hooks rather than CSS classes, so a styling change does not fail the suite.

Step 4: services and HTTP

HttpTestingController is unchanged, and it is still the right tool for testing a service that talks to your Express API:

import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';

describe('TasksApi', () => {
  let api: TasksApi;
  let http: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [TasksApi, provideHttpClient(), provideHttpClientTesting()],
    });
    api = TestBed.inject(TasksApi);
    http = TestBed.inject(HttpTestingController);
  });

  afterEach(() => http.verify());

  it('sends the auth header and unwraps the payload', async () => {
    const promise = api.list();
    const req = http.expectOne('/api/tasks');
    expect(req.request.method).toBe('GET');
    req.flush([{ _id: 'a', title: 'x', done: false }]);
    await expect(promise).resolves.toHaveLength(1);
  });

  it('surfaces a 401 as an auth error', async () => {
    const promise = api.list();
    http.expectOne('/api/tasks').flush({ error: 'invalid token' }, { status: 401, statusText: 'Unauthorized' });
    await expect(promise).rejects.toThrow(/token/);
  });
});

Testing a signal or computed in isolation needs no fixture at all — just an injection context:

it('computes remaining without a component', () => {
  TestBed.runInInjectionContext(() => {
    const tasks = signal([{ done: false }, { done: true }]);
    const remaining = computed(() => tasks().filter((t) => !t.done).length);
    expect(remaining()).toBe(1);
    tasks.update((list) => [...list, { done: false }]);
    expect(remaining()).toBe(2);
  });
});

For effect() and resource(), drive the microtask queue with await TestBed.tick() (or await fixture.whenStable() when a component owns the effect) rather than a bare await Promise.resolve().

Step 5: CI and coverage

Coverage comes from the provider you installed:

ng test --coverage

Set thresholds in vitest.config.ts — the builder merges a root config if one exists — and keep them realistic on a legacy codebase:

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov'],
      thresholds: { lines: 60, functions: 60, branches: 50, statements: 60 },
      exclude: ['**/*.spec.ts', '**/main.ts', '**/*.config.ts'],
    },
  },
});

The GitHub Actions job gets simpler because there is no browser to install:

- uses: actions/setup-node@v4
  with:
    node-version: '24'
    cache: npm
- run: npm ci
- run: npx tsc -p tsconfig.spec.json --noEmit
- run: npx ng test --coverage --reporters=default,junit

Keep the explicit tsc --noEmit step. Vitest transpiles specs without full type checking, exactly as Node 24's type stripping does on the API side, so type errors in test code will otherwise slip through CI unnoticed.

Migration order for a real codebase

On a large app, do not convert everything in one pull request. The sequence that has worked for us:

  1. Land the builder swap with only the setup file and one small spec suite passing; skip the rest with it.skip or a --project filter.
  2. Sweep the mechanical Jasmine-to-Vitest replacements module by module, one PR per feature area, and require a green run before merge.
  3. Fix leakage failures properly, then delete any lingering --browsers=ChromeHeadless flags and Karma documentation.
  4. Re-enable coverage thresholds one notch below the current number and ratchet upward.
  5. Only then reach for the browser mode, and only for the handful of specs that genuinely need real layout.

Budget roughly a day per few hundred specs; suites heavy on jasmine.clock() or DOM globals take longer. The payoff is immediate: watch runs go from tens of seconds to sub-second, CI drops the browser download, and the runner is the same one your Express-side tooling already understands.

Where this fits

Unit tests in Vitest, API tests in node:test or Vitest, and browser journeys in Playwright is the testing shape we recommend for every modern MEAN app — see E2E testing an Angular app with Playwright for the layer above this one, and Express 4 to Express 5 if the server half is still on the old major.

If your Angular app is still pinned to Karma because the suite is too fragile to move, that is usually a symptom rather than the problem. Our MEAN Stack Testing and version migration and upgrade teams do this work as a fixed-scope engagement — get in touch with your Angular version and spec count and we will scope it.