Din v21, Angular CLI folosește Vitest ca runner implicit pentru testele unitare (Karma e deprecat). TestBed rămâne API-ul principal, dar testele moderne arată altfel: input-uri date ca semnale prin bindings, await fixture.whenStable() în loc de fakeAsync (aplicațiile sunt zoneless) și store-uri pe semnale testate fără DOM.
- v21: Vitest implicit (
@angular/build:unit-test), stabil. - v21:
TestBedfurnizează unPlatformLocationfals implicit. - v22:
TestBed.getLastFixture();ChangeDetectorRef.checkNoChangeseliminat (foloseștefixture.detectChanges()). - v22.2:
TestBed.createDirective()pentru testarea directivelor.
Setup
# v21: proiectele noi folosesc Vitest (Karma e deprecat)
ng new my-app # → "test": { "builder": "@angular/build:unit-test" } + vitest
ng test # rulează testele (watch în dev)
ng test --no-watch --coverage
# Proiect vechi pe Karma/Jasmine → migrare asistată
ng generate refactor-jasmine-vitest # schematic experimental din @schematics/angularComponente: input-uri ca semnale
import { TestBed } from '@angular/core/testing';
import { inputBinding, signal } from '@angular/core';
import { StarRating } from './star-rating';
describe('StarRating', () => {
it('afișează numărul corect de stele', async () => {
const value = signal(3);
const fixture = TestBed.createComponent(StarRating, {
bindings: [inputBinding('value', value), inputBinding('max', () => 5)], // input-uri ca semnale
});
await fixture.whenStable(); // zoneless: așteaptă randarea
const stars = fixture.nativeElement.querySelectorAll('button');
expect(stars).toHaveLength(5);
expect(fixture.nativeElement.querySelectorAll('button.on')).toHaveLength(3);
value.set(5); // schimbi input-ul ca pe orice semnal
await fixture.whenStable();
expect(fixture.nativeElement.querySelectorAll('button.on')).toHaveLength(5);
});
it('emite rate la click', async () => {
const onRate = vi.fn();
const fixture = TestBed.createComponent(StarRating, {
bindings: [inputBinding('value', () => 1), outputBinding('rate', onRate)],
});
await fixture.whenStable();
fixture.nativeElement.querySelectorAll('button')[3].click();
expect(onRate).toHaveBeenCalledWith(4);
});
});Store-uri
// Store-uri pe semnale = cel mai ușor de testat: fără DOM, fără async
describe('TodoStore', () => {
let store: TodoStore;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [TodoStore] });
store = TestBed.inject(TodoStore);
});
it('calculează câte au rămas', () => {
store.add('A');
store.add('B');
store.toggle(store.todos()[0].id);
expect(store.remaining()).toBe(1);
});
it('filtrează', () => {
store.add('A');
store.setFilter('done');
expect(store.visible()).toEqual([]);
});
});
// effect-urile rulează asincron — forțează-le cu:
TestBed.tick();- CartStore › pornește gol
- CartStore › count() urmează fiecare add()
- CartStore › același produs de două ori e o linie cu qty 2
- CartStore › total() e preț × cantitate
- CartStore › remove() scoate doar produsul acela
Tests 5 passed (5)
Toate cele 5 teste trec. Acum alege un bug: testele rulează din nou imediat, ca în ng test în watch mode, și vezi care îl prinde.
Aceleași teste, scrise pentru ng test (Vitest + TestBed):
describe('CartStore', () => {
const TEA = { id: 'tea', price: 3.5 };
const MUG = { id: 'mug', price: 4 };
let cart: CartStore;
// Fiecare test are un TestBed nou, deci un CartStore nou
beforeEach(() => (cart = TestBed.inject(CartStore)));
it('pornește gol', () => {
expect(cart.count()).toBe(0);
expect(cart.total()).toBe(0);
});
it('count() urmează fiecare add()', () => {
expect(cart.count()).toBe(0); // citit înainte de add(): asta prinde array-ul modificat pe loc
cart.add(TEA);
expect(cart.count()).toBe(1);
cart.add(MUG);
expect(cart.count()).toBe(2);
});
it('același produs de două ori e o linie cu qty 2', () => {
cart.add(TEA);
cart.add(TEA);
expect(cart.lines()).toHaveLength(1);
expect(cart.lines()[0].qty).toBe(2);
});
it('total() e preț × cantitate', () => {
cart.add(TEA);
cart.add(TEA);
cart.add(MUG);
expect(cart.total()).toBe(11);
});
it('remove() scoate doar produsul acela', () => {
cart.add(TEA);
cart.add(MUG);
cart.remove('tea');
expect(cart.lines().map((l) => l.id)).toEqual(['mug']);
});
});HTTP
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
describe('ProductsApi', () => {
let api: ProductsApi;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
api = TestBed.inject(ProductsApi);
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify()); // niciun request neașteptat
it('mapează DTO → Product', async () => {
const result = firstValueFrom(api.get(1));
http.expectOne('/api/products/1').flush({ id: 1, name: 'Mouse', price: 99, stock: 0 });
expect(await result).toEqual({ id: 1, name: 'Mouse', price: 99, inStock: false });
});
});Directive și fixture-uri (v22)
// v22.2: TestBed.createDirective — testezi o directivă fără componentă-gazdă scrisă de mână
it('aplică highlight la hover', async () => {
const fixture = TestBed.createDirective(Highlight, {
tagName: 'span',
bindings: [inputBinding('appHighlight', () => 'yellow')],
});
fixture.nativeElement.dispatchEvent(new MouseEvent('mouseenter'));
fixture.detectChanges();
expect(fixture.nativeElement.style.backgroundColor).toBe('yellow');
});
// v22: TestBed.getLastFixture() — ultimul fixture creat (util în helpers)
const fixture = TestBed.getLastFixture<MyComponent>();Timere și debounce (zoneless)
// Zoneless: fără fakeAsync/tick din zone.js → timere false din Vitest
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('debounce', async () => {
const fixture = TestBed.createComponent(Search);
fixture.componentInstance.query.set('ang');
await vi.advanceTimersByTimeAsync(300);
await fixture.whenStable();
// ...
});Harness-uri
// Component harnesses (CDK): teste care nu depind de structura DOM
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatButtonHarness } from '@angular/material/button/testing';
const loader = TestbedHarnessEnvironment.loader(fixture);
const save = await loader.getHarness(MatButtonHarness.with({ text: 'Salvează' }));
await save.click();
// Angular Aria are și ea harness-uri: @angular/aria/tabs/testing etc.Ce testezi, unde
| Ce | Cum | Cost |
|---|---|---|
| Funcții pure, mapperi, validatori | teste unitare simple, fără TestBed | minim |
| Store-uri / servicii cu semnale | TestBed.inject, fără DOM | mic |
| Componente dumb | createComponent + bindings | mic |
| Componente smart / pagini | store fals în providers + HttpTestingController | mediu |
| Fluxuri complete | e2e (Playwright / Cypress) | mare |
Store-urile ușor de testat sunt descrise la State management cu signals; un test care prinde un memory leak înainte să ajungă în producție, la Memory leaks: cum le găsești și le repari.
Capcane frecvente
- Testezi implementarea (că s-a apelat o metodă privată) în loc de comportament (ce vede utilizatorul).
- Uiți
await fixture.whenStable()în zoneless → DOM-ul nu e încă actualizat. fakeAsyncfără zone.js nu funcționează → timere false din Vitest.- Effect-uri netestate: rulează asincron —
TestBed.tick()le execută.