Jest
Jest is a fast, batteries-included JavaScript test runner that has become the de facto choice for unit testing modern Angular applications. Unlike the legacy Karma + Jasmine setup that spins up a real browser, Jest runs your tests in a simulated DOM (jsdom) directly in Node, which makes the feedback loop dramatically quicker and the configuration far simpler. With Angular’s official @angular/build:unit-test builder now supporting Jest, plus the mature community preset jest-preset-angular, wiring Jest into an Angular workspace is a short, well-trodden path.
Why Jest over Karma
Karma was officially deprecated by the Angular team, and new workspaces no longer scaffold it by default. Jest fills that gap with a richer feature set and a noticeably better developer experience.
| Concern | Karma + Jasmine | Jest |
|---|---|---|
| Runtime | Real browser | jsdom (Node) |
| Startup | Launches Chrome each run | No browser, instant boot |
| Watch mode | Reruns everything | Reruns only affected tests |
| Parallelism | Single browser context | Multi-worker by default |
| Snapshots | Not built in | First-class |
| Mocking | Manual / Jasmine spies | Powerful auto-mocking + jest.fn() |
| Coverage | Karma coverage plugin | Built-in (--coverage) |
The main trade-off: jsdom is not a real browser, so layout, pixel measurements, and certain native APIs are stubbed. For genuine browser behavior reach for end-to-end tools like Playwright or Cypress instead.
Option 1: the Angular Jest builder (experimental)
Angular 20+ ships an experimental unit-test builder that can use Jest under the hood, so you do not need a third-party preset. Configure it in angular.json:
{
"projects": {
"app": {
"architect": {
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "tsconfig.spec.json",
"runner": "jest",
"buildTarget": "::development"
}
}
}
}
}
}
Then run it through the Angular CLI:
ng test
Because this builder is still experimental, most production projects today still rely on jest-preset-angular, covered next.
Option 2: jest-preset-angular
This is the battle-tested community approach. Install the dependencies:
npm install --save-dev jest jest-preset-angular @types/jest
Create jest.config.ts at the project root:
import type { Config } from 'jest';
const config: Config = {
preset: 'jest-preset-angular',
setupFilesAfterEnv: ['<rootDir>/setup-jest.ts'],
testEnvironment: 'jsdom',
collectCoverageFrom: ['src/app/**/*.ts', '!src/app/**/*.spec.ts'],
};
export default config;
Add setup-jest.ts to initialise the Angular testing environment once per run:
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv();
If your app is zoneless (Angular 18+ with
provideExperimentalZonelessChangeDetection()), import fromjest-preset-angular/setup-env/zonelessinstead so the harness matches your change-detection strategy.
Finally, wire the scripts in package.json:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage"
}
}
Writing a test for a standalone component
Jest uses the same Angular TestBed API you already know, so existing specs need little to no change. Here is a modern standalone component using signals and the new control flow:
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<p>Count: {{ count() }}</p>
@if (count() > 0) {
<span class="positive">positive</span>
}
<button (click)="increment()">+</button>
`,
})
export class CounterComponent {
readonly count = signal(0);
increment(): void {
this.count.update((value) => value + 1);
}
}
The spec drives it through TestBed:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CounterComponent } from './counter.component';
describe('CounterComponent', () => {
let fixture: ComponentFixture<CounterComponent>;
let component: CounterComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CounterComponent],
}).compileComponents();
fixture = TestBed.createComponent(CounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('starts at zero', () => {
expect(component.count()).toBe(0);
});
it('renders the positive flag after a click', () => {
component.increment();
fixture.detectChanges();
const el: HTMLElement = fixture.nativeElement;
expect(el.querySelector('.positive')?.textContent).toBe('positive');
expect(el.querySelector('p')?.textContent).toContain('Count: 1');
});
});
Run it:
npm test
Output:
PASS src/app/counter/counter.component.spec.ts
CounterComponent
✓ starts at zero (24 ms)
✓ renders the positive flag after a click (9 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 1.43 s
Mocking services with jest.fn()
Jest’s mocking is one of its biggest wins over Jasmine. Provide a fake using plain jest.fn() and assert on calls:
import { TestBed } from '@angular/core/testing';
import { UserService } from './user.service';
import { ProfileComponent } from './profile.component';
const userServiceMock = {
load: jest.fn().mockResolvedValue({ id: 1, name: 'Ada' }),
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProfileComponent],
providers: [{ provide: UserService, useValue: userServiceMock }],
}).compileComponents();
});
it('loads the user on init', async () => {
const fixture = TestBed.createComponent(ProfileComponent);
fixture.detectChanges();
await fixture.whenStable();
expect(userServiceMock.load).toHaveBeenCalledTimes(1);
});
Best Practices
- Prefer
jest-preset-angularfor production projects today; reserve the Angular Jest builder for greenfield apps where experimental status is acceptable. - Match your setup file to your change-detection mode (zone vs zoneless) to avoid subtle async failures.
- Call
fixture.detectChanges()after mutating signals or inputs so the rendered DOM reflects the new state before you assert. - Use
jest.fn()mocks anduseValueproviders instead of hitting real services, keeping unit tests fast and deterministic. - Run
jest --watchduring development so only the tests affected by your changes rerun. - Reserve snapshot tests for stable, presentational markup; overusing them on volatile templates creates brittle, noisy diffs.
- Track coverage with
jest --coveragein CI, but treat it as a guide rather than a goal in itself.