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 theDataSourceService.beforeEach(() => { ... });: Sets up the test environment before each test.TestBed.configureTestingModule({})is used, but since our service isprovidedIn: '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’sexpectAPI, which is highly compatible with Jasmine/Jest.- Async Test with
done(): When testing Observables, you often subscribe to them. Thedonecallback 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, andservice.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 ourmockDataSourceServicewheneverDataSourceServiceis requested.
fixture = TestBed.createComponent(ItemListComponent): Creates an instance of our component and aComponentFixtureto 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 forfixture.detectChanges()after async operations if you relied on Zone.js to auto-trigger. Here, it correctly waits for thegetItems().subscribe()call to complete.By.css(): A helper from@angular/platform-browserto query elements within the component’s rendered DOM.vi.fn(): This is Vitest’s equivalent of Jasmine’sspyOnor Jest’sjest.fn(). It creates a mock function that can track calls and allow you to control its behavior. We use it to verifyaddItemwas called.- Simulating User Interaction: For inputs, we set
inputElement.valueand then dispatch aninputevent 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:
- Test Error Handling: Modify
mockDataSourceService.getItemsto return anObservable.throwError('API Failed')(orthrowError(() => new Error('API Failed'))in modern RxJS) whenrefreshItems()is called. - Assert that the component’s
error()signal is set to a meaningful message and that theloading()signal becomesfalse. 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.configureTestingModuleis 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.