EthicsComputerCourses
Join training +91 90532 10052

Testing with Vitest

Angular · Lesson 33 of 41 Advanced 6 min read Updated

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:

Terminal
ng test
ng test --watch=false
ng test --include=src/app/cart
ng test --coverage

By 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:

Output
Test Files  4 passed (4)
     Tests  8 passed (8)
  Duration  6.94s

The 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:

TypeScript
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:

Output
 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():

TypeScript
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 older fixture.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:

TypeScript
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:

TypeScript
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, expect and beforeEach; 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 with await fixture.whenStable().
  • Use provideHttpClientTesting() and HttpTestingController to 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
TypeScript
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?

2. How do you set a component's input in a test?

3. What does HttpTestingController.expectOne(url) do?

4. Why prefer await fixture.whenStable() in zoneless tests?

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