| name | angular-testing |
| description | Write unit and integration tests for Angular v20+ applications using Vitest or Jasmine with TestBed and modern testing patterns. Use for testing components with signals, OnPush change detection, services with inject(), and HTTP interactions. Triggers on test creation, testing signal-based components, mocking dependencies, or setting up test infrastructure. Don't use for E2E testing with Cypress or Playwright, or for testing non-Angular JavaScript/TypeScript code. |
Angular Testing
Test Angular v20+ applications with Vitest (recommended) or Jasmine, focusing on signal-based components and modern patterns.
Zoneless TestBed Requirements
This project runs provideZonelessChangeDetection() at bootstrap. Every TestBed.configureTestingModule call must include it so that ComponentFixture uses the same ZonelessChangeDetectionScheduler as production. Without it, TestBed initialises with a mock Zone.js environment and change detection behaviour diverges from reality.
KIP project note: src/test.ts monkey-patches TestBed.configureTestingModule to inject provideZonelessChangeDetection() globally. New spec files get it automatically. Do not add zone.js to the build or remove the global patch.
Required boilerplate
import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
describe('MyComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MyComponent],
providers: [
provideZonelessChangeDetection(),
],
}).compileComponents();
});
});
TestBed style rules
| Category | Requirement |
|---|
| Provider alignment | Always include provideZonelessChangeDetection() in providers |
| Component type | Prefer standalone components; use imports: not declarations: |
| Manual triggering | Use await fixture.whenStable() or fixture.detectChanges() strategically; rely on signals for reactive state assertions |
| Schema strictness | Use NO_ERRORS_SCHEMA only as last resort; prefer mocking dependencies to preserve type safety |
Vitest Setup (Angular v20+)
Angular v20+ has native Vitest support through the @angular/build package.
npm install -D vitest jsdom
Configure in angular.json:
{
"projects": {
"your-app": {
"architect": {
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "tsconfig.spec.json",
"buildTarget": "your-app:build"
}
}
}
}
}
}
Run tests:
ng test
ng test --watch
ng test --code-coverage
For Vitest migration from Jasmine and advanced configuration, see references/vitest-migration.md.
Basic Component Test
import { describe, it, expect, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { Counter } from './counter.component';
describe('Counter', () => {
let component: Counter;
let fixture: ComponentFixture<Counter>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Counter],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
fixture = TestBed.createComponent(Counter);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should increment count', {
(component.()).();
component.();
(component.()).();
});
(, {
component..();
fixture.();
element = fixture..();
(element.).();
});
});
Testing Signals
Direct Signal Testing
import { signal, computed } from '@angular/core';
describe('Signal logic', () => {
it('should update computed when signal changes', () => {
const count = signal(0);
const doubled = computed(() => count() * 2);
expect(doubled()).toBe(0);
count.set(5);
expect(doubled()).toBe(10);
count.update(c => c + 1);
expect(doubled()).toBe(12);
});
});
Testing Component Signals
@Component({
selector: 'app-todo-list',
template: `
<ul>
@for (todo of filteredTodos(); track todo.id) {
<li>{{ todo.text }}</li>
}
</ul>
<p>{{ remaining() }} remaining</p>
`,
})
export class TodoList {
todos = signal<Todo[]>([]);
filter = signal<'all' | 'active' | 'done'>('all');
filteredTodos = computed(() => {
const todos = this.todos();
switch (this.filter()) {
case 'active': return todos.filter(t => !t.done);
case 'done': return todos.filter(t => t.done);
default: return todos;
}
});
remaining = computed(() => this.todos().filter(t => !t.done).length);
}
describe('TodoList', () => {
let : ;
: <>;
( () => {
.({
: [],
: [()],
}).();
fixture = .();
component = fixture.;
});
(, {
component..([
{ : , : , : },
{ : , : , : },
{ : , : , : },
]);
component..();
(component.().).();
(component.()).();
});
});
Testing OnPush Components
OnPush components require explicit change detection:
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<span>{{ data().name }}</span>`,
})
export class OnPushCmpt {
data = input.required<{ name: string }>();
}
describe('OnPushCmpt', () => {
it('should update when input signal changes', () => {
const fixture = TestBed.createComponent(OnPushCmpt);
fixture.componentRef.setInput('data', { name: 'Initial' });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Initial');
fixture.componentRef.setInput('data', { name: 'Updated' });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Updated');
});
});
Testing Services
Basic Service Test
@Injectable({ providedIn: 'root' })
export class CounterService {
private _count = signal(0);
readonly count = this._count.asReadonly();
increment() { this._count.update(c => c + 1); }
reset() { this._count.set(0); }
}
describe('CounterService', () => {
let service: CounterService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection()],
});
service = TestBed.inject(CounterService);
});
it('should increment count', () => {
expect(service.count()).toBe(0);
service.increment();
expect(service.()).();
});
});
Service with HTTP
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
describe('UserService', () => {
let service: UserService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideZonelessChangeDetection(),
provideHttpClient(),
provideHttpClientTesting(),
],
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should fetch user by id', () => {
const mockUser = { id: '1', name: 'Test User' };
service.getUser('1').subscribe(user => {
(user).(mockUser);
});
req = httpMock.();
(req..).();
req.(mockUser);
});
});
Mocking Dependencies
Using Vitest Mocks
import { describe, it, expect, vi, beforeEach } from 'vitest';
describe('UserProfile', () => {
const mockUserService = {
getUser: vi.fn(),
updateUser: vi.fn(),
user: signal<User | null>(null),
};
beforeEach(async () => {
vi.clearAllMocks();
mockUserService.getUser.mockReturnValue(of({ id: '1', name: 'Test' }));
await TestBed.configureTestingModule({
imports: [UserProfile],
providers: [
provideZonelessChangeDetection(),
{ provide: UserService, useValue: mockUserService },
],
}).compileComponents();
});
it('should call getUser on init', () => {
const fixture = TestBed.createComponent(UserProfile);
fixture.detectChanges();
expect(mockUserService.getUser).toHaveBeenCalledWith();
});
});
Mock Signal-Based Service
const mockAuth = {
user: signal<User | null>(null),
isAuthenticated: computed(() => mockAuth.user() !== null),
login: vi.fn(),
logout: vi.fn(),
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProtectedPage],
providers: [
provideZonelessChangeDetection(),
{ provide: AuthService, useValue: mockAuth },
],
}).compileComponents();
});
it('should show content when authenticated', () => {
mockAuth.user.set({ id: '1', name: 'Test User' });
const fixture = TestBed.createComponent(ProtectedPage);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.protected-content')).toBeTruthy();
});
Testing Inputs and Outputs
@Component({
selector: 'app-item',
template: `<div (click)="select()">{{ item().name }}</div>`,
})
export class ItemCmpt {
item = input.required<Item>();
selected = output<Item>();
select() {
this.selected.emit(this.item());
}
}
describe('ItemCmpt', () => {
it('should emit selected event on click', () => {
const fixture = TestBed.createComponent(ItemCmpt);
const item: Item = { id: '1', name: 'Test Item' };
fixture.componentRef.setInput('item', item);
fixture.detectChanges();
let emittedItem: Item | undefined;
fixture.componentInstance.selected.subscribe(i => emittedItem = i);
fixture.nativeElement.querySelector().();
(emittedItem).(item);
});
});
Testing Async Operations
Using vi.useFakeTimers (zoneless — preferred)
In a zoneless app, use Vitest's own fake timer API. fakeAsync/tick are Zone.js utilities and should not be used in zoneless specs.
it('should debounce search', async () => {
vi.useFakeTimers();
try {
const fixture = TestBed.createComponent(SearchCmpt);
fixture.detectChanges();
fixture.componentInstance.query.set('test');
await vi.advanceTimersByTimeAsync(300);
fixture.detectChanges();
expect(fixture.componentInstance.results().length).toBeGreaterThan(0);
} finally {
vi.useRealTimers();
}
});
Using whenStable
it('should load data', async () => {
const fixture = TestBed.createComponent(DataCmpt);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.componentInstance.data()).toBeDefined();
});
Do not use fakeAsync, tick, or flush from @angular/core/testing in zoneless specs — these utilities depend on Zone.js patching. They are documented on the Zone.js Testing Utilities page as a legacy pattern.
Testing HTTP Resources
@Component({
template: `
@if (userResource.isLoading()) {
<p>Loading...</p>
} @else if (userResource.hasValue()) {
<p>{{ userResource.value().name }}</p>
}
`,
})
export class UserCmpt {
userId = signal('1');
userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
}
describe('UserCmpt', () => {
let httpMock: HttpTestingController;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserCmpt],
providers: [
provideZonelessChangeDetection(),
provideHttpClient(),
provideHttpClientTesting(),
],
}).compileComponents();
httpMock = TestBed.inject(HttpTestingController);
});
it('should display user name after loading', () => {
const fixture = TestBed.createComponent(UserCmpt);
fixture.detectChanges();
expect(fixture.nativeElement.).();
req = httpMock.();
req.({ : , : });
fixture.();
(fixture..).();
});
});
For advanced testing patterns including component harnesses, router testing, form testing, and directive testing, see references/testing-patterns.md.
For Vitest migration from Jasmine, see references/vitest-migration.md.