In the previous chapter, we introduced Vitest and understood why it’s the new default. Now, let’s get our hands dirty and write some actual tests. You’ll find that writing tests with Vitest in Angular feels very familiar if you’ve used Jasmine/Jest before, as Vitest adopts a similar API.

We’ll start with a basic component test and then a service test.

Prerequisite: A new Angular v21 project (e.g., ng new vitest-demo --standalone) should already be configured with Vitest.

Step 1: Create a Simple Service

First, let’s create a straightforward service that we can easily test.

ng generate service data-source --skip-tests

Open src/app/data-source.service.ts and add the following:

// src/app/data-source.service.ts
import { Injectable, signal } from '@angular/core';
import { Observable, of } from 'rxjs';

@Injectable({
  providedIn: 'root',
})
export class DataSourceService {
  private _items = signal<string[]>(['Item 1', 'Item 2', 'Item 3']);

  getItems(): Observable<string[]> {
    return of(this._items()); // Return current items as an Observable
  }

  addItem(item: string): void {
    this._items.update((currentItems) => [...currentItems, item]);
  }

  getItemCount(): number {
    return this._items().length;
  }
}

Explanation: This service holds a list of items using a signal. It provides methods to get items (as an Observable for a realistic scenario), add an item, and get the current count.

Step 2: Write a Unit Test for the Service

Now, let’s create a test file for our DataSourceService.

ng generate service data-source --skip-tests
# Wait, we already made the service. Let's just create the spec file manually.
# Create src/app/data-source.service.spec.ts

Open src/app/data-source.service.spec.ts and add the following code:

// src/app/data-source.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { DataSourceService } from './data-source.service';

// describe block groups related tests
describe('DataSourceService', () => {
  let service: DataSourceService;

  // beforeEach runs before each test in this describe block
  beforeEach(() => {
    // TestBed helps configure and create services/components for testing
    TestBed.configureTestingModule({}); // Empty, as the service is root-provided
    service = TestBed.inject(DataSourceService); // Get an instance of the service
  });

  // it block defines an individual test case
  it('should be created', () => {
    // expect is for making assertions
    expect(service).toBeTruthy();
  });

  it('should return initial items', (done) => {
    // For Observables, you typically subscribe and call done() when complete
    service.getItems().subscribe((items) => {
      expect(items).toEqual(['Item 1', 'Item 2', 'Item 3']);
      done(); // Important for async tests
    });
  });

  it('should add an item and update count', () => {
    const initialCount = service.getItemCount();
    expect(initialCount).toBe(3);

    service.addItem('New Item');

    expect(service.getItemCount()).toBe(initialCount + 1);

    // Verify the new item is present by subscribing again
    service.getItems().subscribe((items) => {
      expect(items).toContain('New Item');
    });
  });

  it('should correctly report item count', () => {
    service.addItem('Another Item');
    expect(service.getItemCount()).toBe(4); // Includes 'Another Item'
  });
});

Explanation:

  • describe('DataSourceService', () => { ... });: Organizes tests for the DataSourceService.
  • beforeEach(() => { ... });: Sets up the test environment before each test. TestBed.configureTestingModule({}) is used, but since our service is providedIn: 'root', it’s automatically available. TestBed.inject(DataSourceService) gets an instance of the service.
  • it('should be created', () => { ... });: A basic test to ensure the service instance is not null.
  • expect(service).toBeTruthy();: An assertion using Vitest’s expect API, which is highly compatible with Jasmine/Jest.
  • Async Test with done(): When testing Observables, you often subscribe to them. The done callback is essential for Vitest (and other async test runners) to know when an asynchronous test has completed.
  • Signal Interactions: Notice how service.addItem() updates the internal signal, and service.getItemCount() reflects the change immediately.

Step 3: Create a Simple Component

Next, let’s create a component that uses our DataSourceService.

ng generate component item-list --standalone --skip-tests

Open src/app/item-list/item-list.component.ts and add the following:

// src/app/item-list/item-list.component.ts
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataSourceService } from '../data-source.service'; // Import our service

@Component({
  selector: 'app-item-list',
  standalone: true,
  imports: [CommonModule],
  template: `
    <h2>Items:</h2>
    <p *ngIf="loading()">Loading items...</p>
    <p *ngIf="error()">Error: {{ error() }}</p>
    <ul *ngIf="!loading() && items().length > 0">
      <li *ngFor="let item of items()">{{ item }}</li>
    </ul>
    <p *ngIf="!loading() && items().length === 0">No items available.</p>
    <button (click)="refreshItems()" [disabled]="loading()">Refresh</button>
    <input #newItemInput type="text" placeholder="Add new item">
    <button (click)="addNewItem(newItemInput.value); newItemInput.value=''">Add Item</button>
  `,
  styles: [`
    ul { list-style-type: disc; margin-left: 20px; }
    li { margin-bottom: 5px; }
    button { margin: 5px; padding: 8px 15px; cursor: pointer; }
    input { margin: 5px; padding: 8px; }
  `]
})
export class ItemListComponent implements OnInit {
  private dataSource = inject(DataSourceService);

  items = signal<string[]>([]);
  loading = signal(false);
  error = signal<string | null>(null);

  ngOnInit(): void {
    this.refreshItems();
  }

  refreshItems(): void {
    this.loading.set(true);
    this.error.set(null);
    this.dataSource.getItems().subscribe({
      next: (data) => {
        this.items.set(data);
        this.loading.set(false);
      },
      error: (err) => {
        this.error.set('Failed to fetch items.');
        this.loading.set(false);
        console.error(err);
      },
    });
  }

  addNewItem(item: string): void {
    if (item.trim()) {
      this.dataSource.addItem(item.trim());
      this.refreshItems(); // Refresh list to show new item
    }
  }
}

Step 4: Write a Unit Test for the Component

Now for the component test. This involves setting up the TestBed to provide the DataSourceService (or a mock of it).

# Create src/app/item-list/item-list.component.spec.ts

Open src/app/item-list/item-list.component.spec.ts and add:

// src/app/item-list/item-list.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ItemListComponent } from './item-list.component';
import { DataSourceService } from '../data-source.service';
import { of } from 'rxjs';
import { By } from '@angular/platform-browser'; // For querying DOM elements

describe('ItemListComponent', () => {
  let component: ItemListComponent;
  let fixture: ComponentFixture<ItemListComponent>;
  let mockDataSourceService: Partial<DataSourceService>;

  beforeEach(async () => {
    // Create a mock for the DataSourceService
    mockDataSourceService = {
      getItems: () => of(['Mock Item 1', 'Mock Item 2']),
      addItem: vi.fn(), // Use vi.fn() for Vitest mocks
      getItemCount: () => 2, // Default mock count
    };

    await TestBed.configureTestingModule({
      imports: [ItemListComponent], // Import the standalone component
      providers: [
        { provide: DataSourceService, useValue: mockDataSourceService }, // Provide the mock
      ],
    }).compileComponents(); // Not strictly necessary for standalone but good practice

    fixture = TestBed.createComponent(ItemListComponent);
    component = fixture.componentInstance;
    fixture.detectChanges(); // Trigger initial change detection
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('should display initial items from service', async () => {
    // fixture.whenStable() waits for all async operations (like service calls) to complete
    await fixture.whenStable();
    // After async operations are done, trigger change detection again
    fixture.detectChanges();

    const listItems = fixture.debugElement.queryAll(By.css('li'));
    expect(listItems.length).toBe(2);
    expect(listItems[0].nativeElement.textContent).toContain('Mock Item 1');
    expect(listItems[1].nativeElement.textContent).toContain('Mock Item 2');
  });

  it('should add a new item when button is clicked', async () => {
    // Arrange: Set up initial state
    const newItem = 'Test Item';
    const inputElement = fixture.debugElement.query(By.css('input')).nativeElement;
    const addButton = fixture.debugElement.queryAll(By.css('button'))[1].nativeElement; // Assuming 2nd button is 'Add Item'

    inputElement.value = newItem;
    inputElement.dispatchEvent(new Event('input')); // Simulate user typing

    // Mock the service behavior for addItem
    mockDataSourceService.addItem = vi.fn(() => {
        // Update the mock items that getItems will return on next call
        mockDataSourceService.getItems = () => of(['Mock Item 1', 'Mock Item 2', newItem]);
    });

    // Act: Trigger the click event
    addButton.click();

    // Assert: Check if addItem was called and UI updated
    expect(mockDataSourceService.addItem).toHaveBeenCalledWith(newItem);

    // Wait for the component's refreshItems to complete its subscription
    await fixture.whenStable();
    fixture.detectChanges();

    const listItems = fixture.debugElement.queryAll(By.css('li'));
    expect(listItems.length).toBe(3);
    expect(listItems[2].nativeElement.textContent).toContain(newItem);
  });

  it('should show loading message while fetching items', () => {
    // Override getItems to return an observable that never completes initially
    // so we can test the loading state
    mockDataSourceService.getItems = () => new Observable(); // An observable that never emits

    // Re-create component to use new mock, trigger ngOnInit
    fixture = TestBed.createComponent(ItemListComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();

    const loadingParagraph = fixture.debugElement.query(By.css('p')).nativeElement;
    expect(loadingParagraph.textContent).toContain('Loading items...');
  });
});

Explanation:

  • TestBed.configureTestingModule: Configures the testing module.
    • imports: [ItemListComponent]: Since it’s a standalone component, we import it directly.
    • providers: [{ provide: DataSourceService, useValue: mockDataSourceService }]: This is crucial! We tell Angular’s injector to use our mockDataSourceService whenever DataSourceService is requested.
  • fixture = TestBed.createComponent(ItemListComponent): Creates an instance of our component and a ComponentFixture to interact with it.
  • fixture.detectChanges(): Triggers Angular’s change detection. Essential to update the component’s view based on its current state.
  • await fixture.whenStable(): In zoneless apps, this is your replacement for fixture.detectChanges() after async operations if you relied on Zone.js to auto-trigger. Here, it correctly waits for the getItems().subscribe() call to complete.
  • By.css(): A helper from @angular/platform-browser to query elements within the component’s rendered DOM.
  • vi.fn(): This is Vitest’s equivalent of Jasmine’s spyOn or Jest’s jest.fn(). It creates a mock function that can track calls and allow you to control its behavior. We use it to verify addItem was called.
  • Simulating User Interaction: For inputs, we set inputElement.value and then dispatch an input event to make Angular’s change detection aware of the change. For buttons, addButton.click() works directly.

Step 5: Run Your Tests

Save all your files and run your tests from the terminal:

ng test

You should see output similar to this, indicating your tests passed:

✓ src/app/data-source.service.spec.ts (4 tests)
    ✓ DataSourceService
        ✓ should be created (1ms)
        ✓ should return initial items (1ms)
        ✓ should add an item and update count (1ms)
        ✓ should correctly report item count (0ms)
✓ src/app/item-list/item-list.component.spec.ts (3 tests)
    ✓ ItemListComponent
        ✓ should create (0ms)
        ✓ should display initial items from service (0ms)
        ✓ should add a new item when button is clicked (1ms)
        ✓ should show loading message while fetching items (0ms)

Test Files  2 passed (2)
     Tests  7 passed (7)
  Start at  12:34:56
  Duration  X.XXs (transform Yms, setup Zms, collect Wms, tests Nms)

Mini-Challenge: Add a New Test Case

Modify the item-list.component.spec.ts to add a new test case:

  1. Test Error Handling: Modify mockDataSourceService.getItems to return an Observable.throwError('API Failed') (or throwError(() => new Error('API Failed')) in modern RxJS) when refreshItems() is called.
  2. Assert that the component’s error() signal is set to a meaningful message and that the loading() signal becomes false. Also, ensure no items are displayed.
// Hint for error handling test
// ... inside the describe block ...

  it('should display an error message if fetching items fails', async () => {
    // Mock getItems to return an error
    mockDataSourceService.getItems = () => throwError(() => new Error('Simulated API Failure'));

    // Re-create component to use new mock, trigger ngOnInit
    fixture = TestBed.createComponent(ItemListComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();

    await fixture.whenStable();
    fixture.detectChanges();

    expect(component.error()).toBe('Failed to fetch items.');
    expect(component.loading()).toBe(false);
    expect(component.items().length).toBe(0);

    const errorMessageElement = fixture.debugElement.query(By.css('p:nth-child(2)')); // Assuming second paragraph for error
    expect(errorMessageElement.nativeElement.textContent).toContain('Error: Failed to fetch items.');
  });

Summary/Key Takeaways

  • Vitest offers a familiar API (describe, it, expect) if you’re coming from Jasmine/Jest.
  • TestBed.configureTestingModule is used to set up the testing environment, including importing standalone components and providing mock services.
  • fixture.detectChanges() is crucial for updating the component’s view.
  • await fixture.whenStable() is vital for waiting for asynchronous operations to complete in zoneless component tests.
  • vi.fn() is Vitest’s mocking utility for spying on methods.
  • Testing components involves querying the DOM with By.css() and simulating user interactions.

This hands-on practice should give you confidence in writing unit tests for your Angular v21 applications using Vitest, embracing the new default testing workflow.