Testing with Vitest
Tests are small programs that check your code still works. When you change the cart logic in six months, a test suite tells you in seconds whether you broke the GST calculation, instead of a customer telling you after a wrong bill. In Angular 22, new projects run unit tests with Vitest, a fast modern test runner, and the CLI sets everything up for you.
Running tests
Every ng generate command creates a .spec.ts file next to the code, and ng test runs them all:
ng test
ng test --watch=false
ng test --include=src/app/cart
ng test --coverageBy default, ng test watches your files and re-runs tests when you save. --watch=false runs once and exits, which is what you want on a CI server. Tests run in Node.js with jsdom, a simulated browser DOM, so they are quick and need no real browser. --coverage reports which lines your tests executed.
A passing run ends like this:
Test Files 4 passed (4)
Tests 8 passed (8)
Duration 6.94sThe shape of a test
Vitest uses three building blocks, available globally in spec files:
describe('name', () => {...})groups related tests.it('does something', () => {...})is one test case.expect(actual).toBe(expected)is an assertion: if it is false, the test fails.
beforeEach runs before every test, so each test starts from a fresh state. Other common matchers are toEqual (deep equality for arrays and objects), toContain, toBeNull, toBeTruthy and toHaveBeenCalledTimes.
Testing a service
Services that hold signals are the easiest things to test. TestBed is Angular's testing utility; TestBed.inject() gives you a service exactly as the app would:
import { TestBed } from '@angular/core/testing';
import { CartStore } from './cart-store';
describe('CartStore', () => {
let cart: CartStore;
beforeEach(() => {
cart = TestBed.inject(CartStore);
});
it('starts empty', () => {
expect(cart.count()).toBe(0);
expect(cart.total()).toBe(0);
});
it('adds lines and updates the total', () => {
cart.add({ name: 'Idli Sambar', price: 40 });
cart.add({ name: 'Lassi', price: 35 });
expect(cart.count()).toBe(2);
expect(cart.total()).toBe(75);
});
});If you break something (or write the wrong expectation, such as 80), Vitest shows exactly where:
FAIL src/app/cart-store.spec.ts > CartStore > adds lines and updates the total AssertionError: expected 75 to be 80 // Object.is equality - Expected + Received - 80 + 75 ❯ src/app/cart-store.spec.ts:21:26
Testing a component
TestBed.createComponent() creates a component with its template, inside a fixture that gives you access to the instance and the rendered DOM. This tests the DishCard from Inputs, Outputs & model():
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Dish, DishCard } from './dish-card';
describe('DishCard', () => {
let fixture: ComponentFixture<DishCard>;
let el: HTMLElement;
const poha: Dish = { id: 1, name: 'Poha', price: 30, veg: true };
beforeEach(async () => {
fixture = TestBed.createComponent(DishCard);
fixture.componentRef.setInput('dish', poha);
await fixture.whenStable();
el = fixture.nativeElement;
});
it('shows the name and price with GST', () => {
expect(el.querySelector('h3')?.textContent).toContain('Poha');
expect(el.textContent).toContain('₹32 incl. GST');
});
it('emits the dish when the button is clicked', () => {
const added: Dish[] = [];
fixture.componentInstance.added.subscribe((d) => added.push(d));
el.querySelector('button')!.click();
expect(added).toEqual([poha]);
});
it('shows "Sold out" instead of the button', async () => {
fixture.componentRef.setInput('soldOut', true);
await fixture.whenStable();
expect(el.querySelector('button')).toBeNull();
expect(el.textContent).toContain('Sold out');
});
});fixture.componentRef.setInput()sets an input the same way a parent template would.await fixture.whenStable()waits until Angular has finished rendering and any pending work. In zoneless apps, prefer it over the olderfixture.detectChanges().- Test what the user sees (text, buttons, emitted events), not private fields. Such tests survive refactoring.
Testing HTTP calls
Never call a real server from unit tests. provideHttpClientTesting() replaces the network with HttpTestingController, which lets you check each request and answer it yourself:
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
describe('MenuApi', () => {
let api: MenuApi;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
api = TestBed.inject(MenuApi);
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
it('searches with query parameters', () => {
let result: MenuItem[] = [];
api.search('dosa', true).subscribe((items) => (result = items));
const req = http.expectOne('/api/menu?q=dosa&veg=true');
expect(req.request.method).toBe('GET');
req.flush([{ id: 7, name: 'Masala Dosa', price: 80, veg: true }]);
expect(result.map((i) => i.name)).toEqual(['Masala Dosa']);
});
});expectOne fails if there is not exactly one matching request, flush sends a fake response, and verify() fails the test if any request was left unanswered.
Replacing a service with a fake
To test a component without its real dependencies, provide a fake object for the service token. vi.fn() creates a mock function that records its calls:
import { of } from 'rxjs';
it('renders items from the service', async () => {
const fakeApi = {
getAll: vi.fn(() => of([{ id: 1, name: 'Vada Pav', price: 25, veg: true }])),
};
TestBed.configureTestingModule({
providers: [{ provide: MenuApi, useValue: fakeApi }],
});
const fixture = TestBed.createComponent(MenuList);
await fixture.whenStable();
expect(fakeApi.getAll).toHaveBeenCalledTimes(1);
expect(fixture.nativeElement.textContent).toContain('Vada Pav - ₹25');
});This is where dependency injection pays off: the component never knows it got a fake.
Common mistakes
- Forgetting
await fixture.whenStable()after changing inputs or signals; the DOM still shows the old values. - Calling real APIs. Tests become slow and flaky. Use
provideHttpClientTesting()or fake services. - Testing private details. Assert on rendered output and public behaviour, not on internal fields.
- One giant test. Small tests with clear names show exactly what broke.
Key points
- New Angular 22 projects run unit tests with Vitest through
ng test, in Node.js with jsdom by default. - Tests use
describe,it,expectandbeforeEach;vi.fn()creates mock functions. TestBed.inject()gets services;TestBed.createComponent()creates components inside a fixture.- Set inputs with
fixture.componentRef.setInput()and wait for rendering withawait fixture.whenStable(). - Use
provideHttpClientTesting()andHttpTestingControllerto test HTTP code without a server.
Exercise
Write tests for the StarRating component from the Inputs lesson. Check that it shows "Not rated" at first, that clicking the third star shows "3 / 5", and that the rating model emits 3 through its ratingChange output when the third star is clicked.
Show solution
import { TestBed } from '@angular/core/testing';
import { StarRating } from './star-rating';
describe('StarRating', () => {
it('starts unrated', async () => {
const fixture = TestBed.createComponent(StarRating);
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain('Not rated');
});
it('rates 3 stars on the third click target', async () => {
const fixture = TestBed.createComponent(StarRating);
await fixture.whenStable();
const changes: number[] = [];
fixture.componentInstance.rating.subscribe((r) => changes.push(r));
fixture.nativeElement.querySelectorAll('button')[2].click();
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain('3 / 5');
expect(changes).toEqual([3]);
});
});A model signal can be subscribed to like an output; it reports every value the component sets. The test clicks a real button and checks the rendered text, so it keeps passing even if the component's internals change.
Quick Quiz
1. Which test runner do new Angular 22 projects use by default?
Vitest is the default runner; Karma is still available with --runner=karma.
2. How do you set a component's input in a test?
setInput sets inputs the same way a parent template does, including signal inputs.
3. What does HttpTestingController.expectOne(url) do?
It captures the request so the test can inspect it and flush a fake response.
4. Why prefer await fixture.whenStable() in zoneless tests?
whenStable resolves once the app is stable, so the DOM reflects the latest state.
Learn Angular with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
