We’ve built a functional user management application, leveraging many Angular v21 features. Now, it’s critical to ensure its reliability and maintainability through testing. In this chapter, we’ll write unit tests for our UserService and UserListComponent using Vitest, which is the new default testing framework in Angular v21.
This will put our knowledge of Vitest, TestBed, mocking, and fixture.whenStable() into practical use.
Prerequisite: Ensure your project is set up for Vitest (which it should be if you started with ng new in v21). Also, have your json-server running for potential integration tests, though our unit tests will primarily mock the HttpClient.
Step 1: Write Unit Tests for UserService
We’ll test UserService’s methods, ensuring they make correct HTTP calls and manage internal state (_users, loadingUsers, errorLoadingUsers) properly. We’ll mock HttpClient to control its responses.
Open
src/app/core/services/user.service.spec.ts. This file was generated by the CLI.Add the following content:
// src/app/core/services/user.service.spec.ts import { TestBed } from '@angular/core/testing'; import { UserService } from './user.service'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { User, NewUser } from '../../shared/models/user.interface'; describe('UserService', () => { let service: UserService; let httpTestingController: HttpTestingController; // To mock HTTP requests beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], // Import this to mock HttpClient providers: [UserService], // Provide the service we are testing }); service = TestBed.inject(UserService); httpTestingController = TestBed.inject(HttpTestingController); }); afterEach(() => { // After every test, ensure that there are no outstanding requests. httpTestingController.verify(); }); it('should be created', () => { expect(service).toBeTruthy(); }); it('should fetch users and update users$ subject', (done) => { const mockUsers: User[] = [ { id: 1, name: 'Test User 1', email: 'test1@example.com', role: 'user' }, { id: 2, name: 'Test User 2', email: 'test2@example.com', role: 'admin' }, ]; // Ensure loading state is set correctly before the call completes service.loadingUsers.subscribe(isLoading => { if (isLoading) { expect(isLoading).toBe(true); } else { // After the request completes, loading should be false expect(isLoading).toBe(false); } }); // Subscribe to users$ to check its emitted values service.users$.subscribe((users) => { // The first emission will be an empty array from BehaviorSubject init // The second emission should be our mock data if (users.length > 0) { expect(users).toEqual(mockUsers); expect(service.errorLoadingUsers.getValue()).toBeNull(); // No error on success done(); // Mark test as complete } }); // Expect a GET request to the user API const req = httpTestingController.expectOne('http://localhost:3000/users'); expect(req.request.method).toEqual('GET'); // Respond with mock data req.flush(mockUsers); }); it('should handle error when fetching users', (done) => { const errorMessage = 'Internal Server Error'; service.loadingUsers.subscribe(isLoading => { // After error, loading should be false if (!isLoading) { expect(isLoading).toBe(false); } }); service.errorLoadingUsers.subscribe(error => { // After error, error message should be set if (error) { expect(error).toContain('Failed to load users'); done(); // Mark test as complete } }); const req = httpTestingController.expectOne('http://localhost:3000/users'); req.flush('Error', { status: 500, statusText: errorMessage }); // Simulate HTTP 500 }); it('should add a new user and update users$ subject', (done) => { const initialUsers: User[] = [{ id: 1, name: 'Initial User', email: 'initial@example.com', role: 'user' }]; service['_users'].next(initialUsers); // Directly set initial state for this test const newUserPayload: NewUser = { name: 'New User', email: 'new@example.com', role: 'guest' }; const addedUser: User = { ...newUserPayload, id: 2 }; // Mock API response with ID service.users$.subscribe((users) => { if (users.length === 2) { // Expect 2 users after adding expect(users).toEqual([...initialUsers, addedUser]); expect(service.loadingUsers.getValue()).toBe(false); done(); } }); service.addUser(newUserPayload).subscribe(); // Trigger the add user method const req = httpTestingController.expectOne('http://localhost:3000/users'); expect(req.request.method).toEqual('POST'); expect(req.request.body).toEqual(newUserPayload); req.flush(addedUser); // Respond with the newly added user }); });
Explanation:
HttpClientTestingModule,HttpTestingController: These are crucial for testing services that useHttpClient.HttpClientTestingModulereplacesHttpClientModuleand providesHttpTestingControllerto intercept and mock HTTP requests made by your service.beforeEach: Sets up the testing module and injectsUserServiceandHttpTestingController.afterEach:httpTestingController.verify()ensures that no unexpected HTTP requests were made.it('should fetch users...'):- We subscribe to
service.loadingUsersandservice.users$to assert on the state changes. httpTestingController.expectOne()captures a specific HTTP request (GETto/users).req.flush(mockUsers)simulates the API responding with our mock data.done()is used because these are asynchronous tests involving subscriptions.
- We subscribe to
it('should handle error...'): Similar to fetching, butreq.flush()is used to simulate an error response (e.g., HTTP 500).it('should add a new user...'):- We directly manipulate the
_usersBehaviorSubjectfor the initial state of this test. httpTestingController.expectOne()captures thePOSTrequest and verifies its body.req.flush(addedUser)simulates a successful API response for adding a user.
- We directly manipulate the
Step 2: Write Unit Tests for UserListComponent
We’ll test UserListComponent’s rendering of users, loading, and error states. We’ll mock UserService to control the data it provides.
Open
src/app/features/users/components/user-list/user-list.component.spec.ts. This file was generated by the CLI.Add the following content:
// src/app/features/users/components/user-list/user-list.component.spec.ts import { ComponentFixture, TestBed } from '@angular/core/testing'; import { UserListComponent } from './user-list.component'; import { UserService } from '../../../../core/services/user.service'; import { BehaviorSubject } from 'rxjs'; import { User } from '../../../../shared/models/user.interface'; import { By } from '@angular/platform-browser'; describe('UserListComponent', () => { let component: UserListComponent; let fixture: ComponentFixture<UserListComponent>; let mockUserService: Partial<UserService>; // Mock BehaviorSubjects for our UserService let mockUsersSubject: BehaviorSubject<User[]>; let mockLoadingSubject: BehaviorSubject<boolean>; let mockErrorSubject: BehaviorSubject<string | null>; beforeEach(async () => { mockUsersSubject = new BehaviorSubject<User[]>([]); mockLoadingSubject = new BehaviorSubject<boolean>(false); mockErrorSubject = new BehaviorSubject<string | null>(null); // Define our mock service mockUserService = { users$: mockUsersSubject.asObservable(), loadingUsers: mockLoadingSubject, errorLoadingUsers: mockErrorSubject, fetchUsers: vi.fn(), // Use vi.fn() to spy on fetchUsers }; await TestBed.configureTestingModule({ imports: [UserListComponent], // Import standalone component providers: [ { provide: UserService, useValue: mockUserService }, // Provide the mock service ], }).compileComponents(); fixture = TestBed.createComponent(UserListComponent); component = fixture.componentInstance; fixture.detectChanges(); // Trigger initial data binding }); it('should create', () => { expect(component).toBeTruthy(); }); it('should display loading message initially', async () => { // Set loading to true and trigger change detection mockLoadingSubject.next(true); fixture.detectChanges(); const loadingMessage = fixture.debugElement.query(By.css('.loading-message p')); expect(loadingMessage).toBeTruthy(); expect(loadingMessage.nativeElement.textContent).toContain('Loading users...'); }); it('should display users when loaded', async () => { const testUsers: User[] = [ { id: 1, name: 'Alice', email: 'alice@example.com', role: 'user' }, { id: 2, name: 'Bob', email: 'bob@example.com', role: 'admin' }, ]; // Simulate successful data load mockLoadingSubject.next(false); mockUsersSubject.next(testUsers); fixture.detectChanges(); // Update component after subjects emit const userCards = fixture.debugElement.queryAll(By.css('.user-card')); expect(userCards.length).toBe(2); expect(userCards[0].nativeElement.textContent).toContain('Alice'); expect(userCards[1].nativeElement.textContent).toContain('Bob'); // Check role class binding const aliceRole = userCards[0].query(By.css('.role-user')); expect(aliceRole).toBeTruthy(); const bobRole = userCards[1].query(By.css('.role-admin')); expect(bobRole).toBeTruthy(); }); it('should display error message on fetch failure', async () => { const errorMessage = 'Failed to load users. Please try again.'; // Simulate error state mockLoadingSubject.next(false); mockErrorSubject.next(errorMessage); fixture.detectChanges(); const errorElement = fixture.debugElement.query(By.css('.error-message p')); expect(errorElement).toBeTruthy(); expect(errorElement.nativeElement.textContent).toContain(errorMessage); const tryAgainButton = fixture.debugElement.query(By.css('.error-message button')); expect(tryAgainButton).toBeTruthy(); }); it('should call fetchUsers when "Try Again" button is clicked', () => { mockLoadingSubject.next(false); mockErrorSubject.next('Some error'); fixture.detectChanges(); const tryAgainButton = fixture.debugElement.query(By.css('.error-message button')).nativeElement; tryAgainButton.click(); // Simulate button click fixture.detectChanges(); expect(mockUserService.fetchUsers).toHaveBeenCalledTimes(1); // Expect the service method to be called }); it('should display "No users found" if users array is empty after loading', () => { mockLoadingSubject.next(false); mockUsersSubject.next([]); // No users fixture.detectChanges(); const noUsersMessage = fixture.debugElement.query(By.css('.no-users-message p')); expect(noUsersMessage).toBeTruthy(); expect(noUsersMessage.nativeElement.textContent).toContain('No users found.'); }); });
Explanation:
mockUserService: Partial<UserService>: We create a mock version of ourUserService.Partialis a TypeScript utility type that makes all properties ofUserServiceoptional, allowing us to only implement what we need for the test.BehaviorSubjectMocks: SinceUserServiceusesBehaviorSubjects, we create mockBehaviorSubjects to control what the component receives forusers$,loadingUsers, anderrorLoadingUsers.provide: UserService, useValue: mockUserService: This tells Angular’s dependency injection system that wheneverUserServiceis requested inUserListComponent, it should provide ourmockUserServiceinstead of the real one.fixture.detectChanges(): Crucial for updating the component’s template. After each change to mock data, we call this to ensure the DOM reflects the new state.By.css(): Used to query for elements within the component’s rendered output based on their CSS selectors.vi.fn(): We use this onmockUserService.fetchUsersto spy on whether it was called.- Simulating User Interaction: We simulate button clicks (
.nativeElement.click()) to trigger component methods.
Step 3: Run Your Tests
Save all your files and run your tests from the terminal:
ng test
You should see output similar to this, indicating all your tests passed:
✓ src/app/core/services/user.service.spec.ts (4 tests)
✓ UserService
✓ should be created (1ms)
✓ should fetch users and update users$ subject (2ms)
✓ should handle error when fetching users (1ms)
✓ should add a new user and update users$ subject (1ms)
✓ src/app/features/users/components/user-list/user-list.component.spec.ts (6 tests)
✓ UserListComponent
✓ should create (0ms)
✓ should display loading message initially (0ms)
✓ should display users when loaded (0ms)
✓ should display error message on fetch failure (0ms)
✓ should call fetchUsers when "Try Again" button is clicked (0ms)
✓ should display "No users found" if users array is empty after loading (0ms)
Test Files 2 passed (2)
Tests 10 passed (10)
Start at HH:MM:SS
Duration X.XXs
Mini-Challenge: Test UserFormComponent (Conceptual)
Think about how you would test the UserFormComponent.
- What parts of the
UserFormComponentwould you want to test (e.g., initial state, validation, submission)? - How would you mock the
UserServiceforUserFormComponent? (Hint: You’d want to control itsaddUsermethod and its loading/errorBehaviorSubjects). - How would you simulate user input into the form fields?
- How would you assert that validation messages appear/disappear correctly?
(This is a conceptual challenge, you don’t need to implement it fully, but thinking through it will solidify your understanding of testing forms with Vitest.)
Summary/Key Takeaways
- We successfully wrote unit tests for our
UserServiceusingHttpClientTestingModuleandHttpTestingControllerto mock API responses. - We created unit tests for our
UserListComponentby providing a mockUserServicethat allows us to control the data and states (users$,loadingUsers,errorLoadingUsers). - Vitest’s syntax (
describe,it,expect,vi.fn()) feels familiar and integrates well with Angular’sTestBed. - We utilized
fixture.detectChanges()and simulated user interactions to thoroughly test component behavior. - Mocking dependencies is essential for isolating the component/service under test and ensuring predictable test results.
This concludes our guided project! You’ve not only built a functional application using Angular v21’s latest features but also gained valuable experience in testing it with the new default framework. This foundation will serve you well in building and maintaining robust Angular applications.