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.

  1. Open src/app/core/services/user.service.spec.ts. This file was generated by the CLI.

  2. 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 use HttpClient. HttpClientTestingModule replaces HttpClientModule and provides HttpTestingController to intercept and mock HTTP requests made by your service.
  • beforeEach: Sets up the testing module and injects UserService and HttpTestingController.
  • afterEach: httpTestingController.verify() ensures that no unexpected HTTP requests were made.
  • it('should fetch users...'):
    • We subscribe to service.loadingUsers and service.users$ to assert on the state changes.
    • httpTestingController.expectOne() captures a specific HTTP request (GET to /users).
    • req.flush(mockUsers) simulates the API responding with our mock data.
    • done() is used because these are asynchronous tests involving subscriptions.
  • it('should handle error...'): Similar to fetching, but req.flush() is used to simulate an error response (e.g., HTTP 500).
  • it('should add a new user...'):
    • We directly manipulate the _users BehaviorSubject for the initial state of this test.
    • httpTestingController.expectOne() captures the POST request and verifies its body.
    • req.flush(addedUser) simulates a successful API response for adding a user.

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.

  1. Open src/app/features/users/components/user-list/user-list.component.spec.ts. This file was generated by the CLI.

  2. 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 our UserService. Partial is a TypeScript utility type that makes all properties of UserService optional, allowing us to only implement what we need for the test.
  • BehaviorSubject Mocks: Since UserService uses BehaviorSubjects, we create mock BehaviorSubjects to control what the component receives for users$, loadingUsers, and errorLoadingUsers.
  • provide: UserService, useValue: mockUserService: This tells Angular’s dependency injection system that whenever UserService is requested in UserListComponent, it should provide our mockUserService instead 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 on mockUserService.fetchUsers to 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.

  1. What parts of the UserFormComponent would you want to test (e.g., initial state, validation, submission)?
  2. How would you mock the UserService for UserFormComponent? (Hint: You’d want to control its addUser method and its loading/error BehaviorSubjects).
  3. How would you simulate user input into the form fields?
  4. 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 UserService using HttpClientTestingModule and HttpTestingController to mock API responses.
  • We created unit tests for our UserListComponent by providing a mock UserService that 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’s TestBed.
  • 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.