Skip to content

19. Advanced Angular Testing

Learning Objectives

By the end of this module, you will be able to:

  • Implement comprehensive testing strategies for Angular applications
  • Write unit tests for complex components, services, and directives
  • Create integration tests for component interactions
  • Test asynchronous operations and observables effectively
  • Use advanced testing utilities and patterns
  • Implement end-to-end testing with modern tools
  • Apply test-driven development (TDD) practices
  • Optimize testing performance and reliability

Testing Strategy and Architecture

Testing Pyramid Implementation

// Test configuration and architecture
export class TestingStrategy {
  // Unit Tests (70% of tests)
  static readonly UNIT_TEST_RATIO = 0.7;

  // Integration Tests (20% of tests)
  static readonly INTEGRATION_TEST_RATIO = 0.2;

  // E2E Tests (10% of tests)
  static readonly E2E_TEST_RATIO = 0.1;

  static getTestingPlan(totalTests: number) {
    return {
      unitTests: Math.floor(totalTests * this.UNIT_TEST_RATIO),
      integrationTests: Math.floor(totalTests * this.INTEGRATION_TEST_RATIO),
      e2eTests: Math.floor(totalTests * this.E2E_TEST_RATIO)
    };
  }
}

// Base test setup
export abstract class BaseTestSetup {
  protected component: any;
  protected fixture: ComponentFixture<any>;
  protected debugElement: DebugElement;

  protected async setupComponent<T>(
    componentType: Type<T>,
    config?: Partial<TestModuleMetadata>
  ): Promise<void> {
    await TestBed.configureTestingModule({
      declarations: [componentType],
      imports: [CommonModule, ...config?.imports || []],
      providers: [...config?.providers || []],
      schemas: [CUSTOM_ELEMENTS_SCHEMA]
    }).compileComponents();

    this.fixture = TestBed.createComponent(componentType);
    this.component = this.fixture.componentInstance;
    this.debugElement = this.fixture.debugElement;
  }

  protected detectChanges(): void {
    this.fixture.detectChanges();
  }

  protected queryByTestId(testId: string): DebugElement {
    return this.debugElement.query(By.css(`[data-testid="${testId}"]`));
  }

  protected queryAllByTestId(testId: string): DebugElement[] {
    return this.debugElement.queryAll(By.css(`[data-testid="${testId}"]`));
  }
}

Test Data Factories

// Test data factory pattern
export class TestDataFactory {
  static createUser(overrides: Partial<User> = {}): User {
    return {
      id: Math.floor(Math.random() * 1000),
      name: 'Test User',
      email: 'test@example.com',
      role: 'user',
      createdAt: new Date(),
      isActive: true,
      ...overrides
    };
  }

  static createUsers(count: number, overrides: Partial<User> = {}): User[] {
    return Array.from({ length: count }, (_, index) =>
      this.createUser({ id: index + 1, ...overrides })
    );
  }

  static createProduct(overrides: Partial<Product> = {}): Product {
    return {
      id: Math.floor(Math.random() * 1000),
      name: 'Test Product',
      price: 99.99,
      category: 'Electronics',
      inStock: true,
      description: 'Test product description',
      ...overrides
    };
  }

  static createApiResponse<T>(data: T, overrides: Partial<ApiResponse<T>> = {}): ApiResponse<T> {
    return {
      data,
      success: true,
      message: 'Success',
      timestamp: new Date().toISOString(),
      ...overrides
    };
  }
}

// Mock data builders
export class UserBuilder {
  private user: Partial<User> = {};

  withId(id: number): UserBuilder {
    this.user.id = id;
    return this;
  }

  withName(name: string): UserBuilder {
    this.user.name = name;
    return this;
  }

  withEmail(email: string): UserBuilder {
    this.user.email = email;
    return this;
  }

  withRole(role: string): UserBuilder {
    this.user.role = role;
    return this;
  }

  inactive(): UserBuilder {
    this.user.isActive = false;
    return this;
  }

  build(): User {
    return TestDataFactory.createUser(this.user);
  }
}

// Usage in tests
describe('UserService', () => {
  let service: UserService;

  beforeEach(() => {
    TestBed.configureTestingModule({});
    service = TestBed.inject(UserService);
  });

  it('should create admin user', () => {
    const adminUser = new UserBuilder()
      .withId(1)
      .withName('Admin User')
      .withRole('admin')
      .build();

    expect(adminUser.role).toBe('admin');
    expect(adminUser.name).toBe('Admin User');
  });
});

Advanced Unit Testing

Complex Component Testing

@Component({
  selector: 'app-user-management',
  template: `
    <div class="user-management">
      <div class="search-section">
        <input
          data-testid="search-input"
          [(ngModel)]="searchTerm"
          (input)="onSearch()"
          placeholder="Search users...">

        <button
          data-testid="add-user-btn"
          (click)="openAddUserModal()"
          [disabled]="!canAddUser">
          Add User
        </button>
      </div>

      <div class="user-list" data-testid="user-list">
        <div
          *ngFor="let user of filteredUsers; trackBy: trackByUserId"
          class="user-item"
          [data-testid]="'user-item-' + user.id">

          <span>{{ user.name }}</span>
          <span>{{ user.email }}</span>

          <button
            [data-testid]="'edit-btn-' + user.id"
            (click)="editUser(user)"
            [disabled]="!canEditUser(user)">
            Edit
          </button>

          <button
            [data-testid]="'delete-btn-' + user.id"
            (click)="deleteUser(user.id)"
            [disabled]="!canDeleteUser(user)">
            Delete
          </button>
        </div>
      </div>

      <app-pagination
        [currentPage]="currentPage"
        [totalPages]="totalPages"
        (pageChange)="onPageChange($event)">
      </app-pagination>
    </div>
  `
})
export class UserManagementComponent implements OnInit {
  @Input() users: User[] = [];
  @Input() currentUser?: User;
  @Output() userAdded = new EventEmitter<User>();
  @Output() userUpdated = new EventEmitter<User>();
  @Output() userDeleted = new EventEmitter<number>();

  searchTerm = '';
  filteredUsers: User[] = [];
  currentPage = 1;
  pageSize = 10;
  totalPages = 0;

  constructor(
    private userService: UserService,
    private modalService: ModalService,
    private permissionService: PermissionService
  ) {}

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

  get canAddUser(): boolean {
    return this.permissionService.hasPermission('user:create');
  }

  onSearch(): void {
    this.currentPage = 1;
    this.updateFilteredUsers();
  }

  canEditUser(user: User): boolean {
    return this.permissionService.hasPermission('user:edit') ||
           this.currentUser?.id === user.id;
  }

  canDeleteUser(user: User): boolean {
    return this.permissionService.hasPermission('user:delete') &&
           this.currentUser?.id !== user.id;
  }

  trackByUserId(index: number, user: User): number {
    return user.id;
  }

  async openAddUserModal(): Promise<void> {
    const modalRef = await this.modalService.open(AddUserModalComponent);
    modalRef.result.subscribe((newUser: User) => {
      this.userAdded.emit(newUser);
    });
  }

  async editUser(user: User): Promise<void> {
    const modalRef = await this.modalService.open(EditUserModalComponent, {
      data: { user }
    });
    modalRef.result.subscribe((updatedUser: User) => {
      this.userUpdated.emit(updatedUser);
    });
  }

  async deleteUser(userId: number): Promise<void> {
    const confirmed = await this.modalService.confirm(
      'Delete User',
      'Are you sure you want to delete this user?'
    );

    if (confirmed) {
      this.userDeleted.emit(userId);
    }
  }

  onPageChange(page: number): void {
    this.currentPage = page;
    this.updateFilteredUsers();
  }

  private updateFilteredUsers(): void {
    let filtered = this.users;

    if (this.searchTerm) {
      filtered = this.users.filter(user =>
        user.name.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
        user.email.toLowerCase().includes(this.searchTerm.toLowerCase())
      );
    }

    this.totalPages = Math.ceil(filtered.length / this.pageSize);
    const startIndex = (this.currentPage - 1) * this.pageSize;
    this.filteredUsers = filtered.slice(startIndex, startIndex + this.pageSize);
  }
}

// Comprehensive test suite
describe('UserManagementComponent', () => {
  let component: UserManagementComponent;
  let fixture: ComponentFixture<UserManagementComponent>;
  let userService: jasmine.SpyObj<UserService>;
  let modalService: jasmine.SpyObj<ModalService>;
  let permissionService: jasmine.SpyObj<PermissionService>;

  const mockUsers = TestDataFactory.createUsers(25);

  beforeEach(async () => {
    const userServiceSpy = jasmine.createSpyObj('UserService', ['getUsers', 'createUser']);
    const modalServiceSpy = jasmine.createSpyObj('ModalService', ['open', 'confirm']);
    const permissionServiceSpy = jasmine.createSpyObj('PermissionService', ['hasPermission']);

    await TestBed.configureTestingModule({
      declarations: [UserManagementComponent, MockPaginationComponent],
      imports: [FormsModule],
      providers: [
        { provide: UserService, useValue: userServiceSpy },
        { provide: ModalService, useValue: modalServiceSpy },
        { provide: PermissionService, useValue: permissionServiceSpy }
      ]
    }).compileComponents();

    fixture = TestBed.createComponent(UserManagementComponent);
    component = fixture.componentInstance;

    userService = TestBed.inject(UserService) as jasmine.SpyObj<UserService>;
    modalService = TestBed.inject(ModalService) as jasmine.SpyObj<ModalService>;
    permissionService = TestBed.inject(PermissionService) as jasmine.SpyObj<PermissionService>;
  });

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

    it('should initialize with correct default values', () => {
      expect(component.searchTerm).toBe('');
      expect(component.currentPage).toBe(1);
      expect(component.pageSize).toBe(10);
    });

    it('should call updateFilteredUsers on init', () => {
      spyOn(component, 'updateFilteredUsers' as any);
      component.ngOnInit();
      expect(component['updateFilteredUsers']).toHaveBeenCalled();
    });
  });

  describe('User Display and Filtering', () => {
    beforeEach(() => {
      component.users = mockUsers;
      component.ngOnInit();
      fixture.detectChanges();
    });

    it('should display first page of users', () => {
      const userItems = fixture.debugElement.queryAll(
        By.css('[data-testid^="user-item-"]')
      );
      expect(userItems.length).toBe(10); // First page
    });

    it('should filter users by search term', fakeAsync(() => {
      const searchInput = fixture.debugElement.query(
        By.css('[data-testid="search-input"]')
      );

      searchInput.nativeElement.value = 'User 1';
      searchInput.nativeElement.dispatchEvent(new Event('input'));
      tick();
      fixture.detectChanges();

      const userItems = fixture.debugElement.queryAll(
        By.css('[data-testid^="user-item-"]')
      );

      // Should show users with "User 1" in name (User 1, User 10, User 11, etc.)
      expect(userItems.length).toBeGreaterThan(0);
      expect(userItems.length).toBeLessThan(10);
    }));

    it('should reset to first page when searching', () => {
      component.currentPage = 3;
      component.onSearch();
      expect(component.currentPage).toBe(1);
    });
  });

  describe('Permissions and Actions', () => {
    beforeEach(() => {
      component.users = mockUsers.slice(0, 5);
      component.currentUser = mockUsers[0];
      fixture.detectChanges();
    });

    it('should enable add user button when user has permission', () => {
      permissionService.hasPermission.and.returnValue(true);
      fixture.detectChanges();

      const addButton = fixture.debugElement.query(
        By.css('[data-testid="add-user-btn"]')
      );
      expect(addButton.nativeElement.disabled).toBeFalsy();
    });

    it('should disable add user button when user lacks permission', () => {
      permissionService.hasPermission.and.returnValue(false);
      fixture.detectChanges();

      const addButton = fixture.debugElement.query(
        By.css('[data-testid="add-user-btn"]')
      );
      expect(addButton.nativeElement.disabled).toBeTruthy();
    });

    it('should allow user to edit their own profile', () => {
      permissionService.hasPermission.and.returnValue(false);

      const canEdit = component.canEditUser(component.currentUser!);
      expect(canEdit).toBeTruthy();
    });

    it('should prevent user from deleting themselves', () => {
      permissionService.hasPermission.and.returnValue(true);

      const canDelete = component.canDeleteUser(component.currentUser!);
      expect(canDelete).toBeFalsy();
    });
  });

  describe('Modal Interactions', () => {
    it('should open add user modal and emit event on success', async () => {
      const newUser = TestDataFactory.createUser({ name: 'New User' });
      const modalRef = {
        result: of(newUser)
      };
      modalService.open.and.returnValue(Promise.resolve(modalRef as any));

      spyOn(component.userAdded, 'emit');

      await component.openAddUserModal();

      expect(modalService.open).toHaveBeenCalledWith(AddUserModalComponent);
      expect(component.userAdded.emit).toHaveBeenCalledWith(newUser);
    });

    it('should open edit modal with user data', async () => {
      const userToEdit = mockUsers[0];
      const updatedUser = { ...userToEdit, name: 'Updated Name' };
      const modalRef = {
        result: of(updatedUser)
      };
      modalService.open.and.returnValue(Promise.resolve(modalRef as any));

      spyOn(component.userUpdated, 'emit');

      await component.editUser(userToEdit);

      expect(modalService.open).toHaveBeenCalledWith(EditUserModalComponent, {
        data: { user: userToEdit }
      });
      expect(component.userUpdated.emit).toHaveBeenCalledWith(updatedUser);
    });

    it('should confirm deletion before emitting delete event', async () => {
      const userIdToDelete = 1;
      modalService.confirm.and.returnValue(Promise.resolve(true));

      spyOn(component.userDeleted, 'emit');

      await component.deleteUser(userIdToDelete);

      expect(modalService.confirm).toHaveBeenCalled();
      expect(component.userDeleted.emit).toHaveBeenCalledWith(userIdToDelete);
    });

    it('should not emit delete event if deletion is cancelled', async () => {
      modalService.confirm.and.returnValue(Promise.resolve(false));

      spyOn(component.userDeleted, 'emit');

      await component.deleteUser(1);

      expect(component.userDeleted.emit).not.toHaveBeenCalled();
    });
  });

  describe('Pagination', () => {
    beforeEach(() => {
      component.users = mockUsers; // 25 users
      component.ngOnInit();
    });

    it('should calculate correct total pages', () => {
      expect(component.totalPages).toBe(3); // 25 users / 10 per page = 3 pages
    });

    it('should change page and update filtered users', () => {
      component.onPageChange(2);

      expect(component.currentPage).toBe(2);
      expect(component.filteredUsers.length).toBe(10);
      expect(component.filteredUsers[0].id).toBe(11); // Second page starts with user 11
    });

    it('should emit page change event to pagination component', () => {
      const paginationComponent = fixture.debugElement.query(
        By.css('app-pagination')
      );

      expect(paginationComponent.componentInstance.currentPage).toBe(1);

      component.onPageChange(2);
      fixture.detectChanges();

      expect(paginationComponent.componentInstance.currentPage).toBe(2);
    });
  });

  describe('TrackBy Function', () => {
    it('should return user id for tracking', () => {
      const user = mockUsers[0];
      const result = component.trackByUserId(0, user);
      expect(result).toBe(user.id);
    });
  });
});

// Mock components for testing
@Component({
  selector: 'app-pagination',
  template: '<div>Pagination Mock</div>'
})
class MockPaginationComponent {
  @Input() currentPage: number = 1;
  @Input() totalPages: number = 1;
  @Output() pageChange = new EventEmitter<number>();
}

Component Integration Testing

Testing Component Communication

// Parent-Child component integration test
@Component({
  selector: 'app-test-parent',
  template: `
    <app-user-list
      [users]="users"
      [loading]="loading"
      (userSelected)="onUserSelected($event)"
      (userDeleted)="onUserDeleted($event)">
    </app-user-list>

    <app-user-details
      *ngIf="selectedUser"
      [user]="selectedUser"
      (userUpdated)="onUserUpdated($event)"
      (closeRequested)="onCloseDetails()">
    </app-user-details>
  `
})
class TestParentComponent {
  users: User[] = [];
  selectedUser?: User;
  loading = false;

  onUserSelected(user: User): void {
    this.selectedUser = user;
  }

  onUserDeleted(userId: number): void {
    this.users = this.users.filter(u => u.id !== userId);
    if (this.selectedUser?.id === userId) {
      this.selectedUser = undefined;
    }
  }

  onUserUpdated(updatedUser: User): void {
    this.users = this.users.map(u =>
      u.id === updatedUser.id ? updatedUser : u
    );
    this.selectedUser = updatedUser;
  }

  onCloseDetails(): void {
    this.selectedUser = undefined;
  }
}

describe('User Management Integration', () => {
  let component: TestParentComponent;
  let fixture: ComponentFixture<TestParentComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [
        TestParentComponent,
        UserListComponent,
        UserDetailsComponent
      ],
      imports: [CommonModule, FormsModule]
    }).compileComponents();

    fixture = TestBed.createComponent(TestParentComponent);
    component = fixture.componentInstance;
  });

  describe('User Selection Flow', () => {
    beforeEach(() => {
      component.users = TestDataFactory.createUsers(3);
      fixture.detectChanges();
    });

    it('should display user details when user is selected', () => {
      const userListComponent = fixture.debugElement.query(
        By.directive(UserListComponent)
      ).componentInstance;

      // Simulate user selection
      userListComponent.userSelected.emit(component.users[0]);
      fixture.detectChanges();

      const userDetailsComponent = fixture.debugElement.query(
        By.directive(UserDetailsComponent)
      );

      expect(userDetailsComponent).toBeTruthy();
      expect(userDetailsComponent.componentInstance.user).toEqual(component.users[0]);
    });

    it('should hide user details when close is requested', () => {
      // Select a user first
      component.selectedUser = component.users[0];
      fixture.detectChanges();

      const userDetailsComponent = fixture.debugElement.query(
        By.directive(UserDetailsComponent)
      ).componentInstance;

      // Simulate close request
      userDetailsComponent.closeRequested.emit();
      fixture.detectChanges();

      const updatedUserDetailsComponent = fixture.debugElement.query(
        By.directive(UserDetailsComponent)
      );

      expect(updatedUserDetailsComponent).toBeFalsy();
    });
  });

  describe('User Deletion Flow', () => {
    beforeEach(() => {
      component.users = TestDataFactory.createUsers(3);
      component.selectedUser = component.users[0];
      fixture.detectChanges();
    });

    it('should remove user from list and clear selection when deleted user is selected', () => {
      const userListComponent = fixture.debugElement.query(
        By.directive(UserListComponent)
      ).componentInstance;

      const userToDelete = component.users[0];
      const initialLength = component.users.length;

      // Simulate user deletion
      userListComponent.userDeleted.emit(userToDelete.id);
      fixture.detectChanges();

      expect(component.users.length).toBe(initialLength - 1);
      expect(component.users.find(u => u.id === userToDelete.id)).toBeUndefined();
      expect(component.selectedUser).toBeUndefined();
    });
  });

  describe('User Update Flow', () => {
    beforeEach(() => {
      component.users = TestDataFactory.createUsers(3);
      component.selectedUser = component.users[0];
      fixture.detectChanges();
    });

    it('should update user in list and selected user when user is updated', () => {
      const userDetailsComponent = fixture.debugElement.query(
        By.directive(UserDetailsComponent)
      ).componentInstance;

      const updatedUser = { ...component.users[0], name: 'Updated Name' };

      // Simulate user update
      userDetailsComponent.userUpdated.emit(updatedUser);
      fixture.detectChanges();

      const userInList = component.users.find(u => u.id === updatedUser.id);
      expect(userInList?.name).toBe('Updated Name');
      expect(component.selectedUser?.name).toBe('Updated Name');
    });
  });
});

Testing Asynchronous Code

Testing Promises and Async/Await

@Injectable({
  providedIn: 'root'
})
export class AsyncDataService {
  constructor(private http: HttpClient) {}

  async loadUserData(userId: number): Promise<UserData> {
    try {
      const user = await this.http.get<User>(`/api/users/${userId}`).toPromise();
      const profile = await this.http.get<Profile>(`/api/profiles/${userId}`).toPromise();
      const permissions = await this.http.get<Permission[]>(`/api/users/${userId}/permissions`).toPromise();

      return {
        user,
        profile,
        permissions
      };
    } catch (error) {
      throw new Error(`Failed to load user data: ${error}`);
    }
  }

  async batchUpdateUsers(updates: UserUpdate[]): Promise<BatchUpdateResult> {
    const results = await Promise.allSettled(
      updates.map(update =>
        this.http.put<User>(`/api/users/${update.userId}`, update.data).toPromise()
      )
    );

    const successful = results
      .filter((result): result is PromiseFulfilledResult<User> =>
        result.status === 'fulfilled'
      )
      .map(result => result.value);

    const failed = results
      .filter((result): result is PromiseRejectedResult =>
        result.status === 'rejected'
      )
      .map(result => result.reason);

    return {
      successful,
      failed,
      totalProcessed: results.length
    };
  }

  async retryOperation<T>(
    operation: () => Promise<T>,
    maxRetries: number = 3,
    delay: number = 1000
  ): Promise<T> {
    let lastError: Error;

    for (let attempt = 1; attempt <= maxRetries; attempt++) {
      try {
        return await operation();
      } catch (error) {
        lastError = error as Error;

        if (attempt < maxRetries) {
          await this.delay(delay * attempt); // Exponential backoff
        }
      }
    }

    throw new Error(`Operation failed after ${maxRetries} attempts: ${lastError!.message}`);
  }

  private delay(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

// Comprehensive async testing
describe('AsyncDataService', () => {
  let service: AsyncDataService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [AsyncDataService]
    });

    service = TestBed.inject(AsyncDataService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpMock.verify();
  });

  describe('loadUserData', () => {
    const userId = 1;
    const mockUser = TestDataFactory.createUser({ id: userId });
    const mockProfile = { userId, bio: 'Test bio', avatar: 'avatar.jpg' };
    const mockPermissions = [{ id: 1, name: 'read' }, { id: 2, name: 'write' }];

    it('should load user data successfully', async () => {
      const loadDataPromise = service.loadUserData(userId);

      // Expect and respond to all HTTP requests
      const userReq = httpMock.expectOne(`/api/users/${userId}`);
      const profileReq = httpMock.expectOne(`/api/profiles/${userId}`);
      const permissionsReq = httpMock.expectOne(`/api/users/${userId}/permissions`);

      userReq.flush(mockUser);
      profileReq.flush(mockProfile);
      permissionsReq.flush(mockPermissions);

      const result = await loadDataPromise;

      expect(result).toEqual({
        user: mockUser,
        profile: mockProfile,
        permissions: mockPermissions
      });
    });

    it('should handle errors during data loading', async () => {
      const loadDataPromise = service.loadUserData(userId);

      // First request succeeds
      const userReq = httpMock.expectOne(`/api/users/${userId}`);
      userReq.flush(mockUser);

      // Second request fails
      const profileReq = httpMock.expectOne(`/api/profiles/${userId}`);
      profileReq.error(new ErrorEvent('Network error'));

      await expectAsync(loadDataPromise).toBeRejectedWithError(/Failed to load user data/);
    });

    it('should handle timeout scenarios', fakeAsync(() => {
      let result: UserData | undefined;
      let error: Error | undefined;

      service.loadUserData(userId)
        .then(data => result = data)
        .catch(err => error = err);

      // Don't respond to requests to simulate timeout
      httpMock.expectOne(`/api/users/${userId}`);

      tick(30000); // Simulate 30 second timeout

      expect(result).toBeUndefined();
      expect(error).toBeDefined();
    }));
  });

  describe('batchUpdateUsers', () => {
    const updates: UserUpdate[] = [
      { userId: 1, data: { name: 'Updated User 1' } },
      { userId: 2, data: { name: 'Updated User 2' } },
      { userId: 3, data: { name: 'Updated User 3' } }
    ];

    it('should handle successful batch updates', async () => {
      const batchPromise = service.batchUpdateUsers(updates);

      // Respond to all requests successfully
      updates.forEach((update, index) => {
        const req = httpMock.expectOne(`/api/users/${update.userId}`);
        req.flush(TestDataFactory.createUser({
          id: update.userId,
          ...update.data
        }));
      });

      const result = await batchPromise;

      expect(result.successful.length).toBe(3);
      expect(result.failed.length).toBe(0);
      expect(result.totalProcessed).toBe(3);
    });

    it('should handle partial failures in batch updates', async () => {
      const batchPromise = service.batchUpdateUsers(updates);

      // First request succeeds
      const req1 = httpMock.expectOne(`/api/users/1`);
      req1.flush(TestDataFactory.createUser({ id: 1, name: 'Updated User 1' }));

      // Second request fails
      const req2 = httpMock.expectOne(`/api/users/2`);
      req2.error(new ErrorEvent('Update failed'));

      // Third request succeeds
      const req3 = httpMock.expectOne(`/api/users/3`);
      req3.flush(TestDataFactory.createUser({ id: 3, name: 'Updated User 3' }));

      const result = await batchPromise;

      expect(result.successful.length).toBe(2);
      expect(result.failed.length).toBe(1);
      expect(result.totalProcessed).toBe(3);
    });
  });

  describe('retryOperation', () => {
    it('should succeed on first attempt', async () => {
      const operation = jasmine.createSpy('operation').and.returnValue(
        Promise.resolve('success')
      );

      const result = await service.retryOperation(operation);

      expect(result).toBe('success');
      expect(operation).toHaveBeenCalledTimes(1);
    });

    it('should retry on failure and eventually succeed', fakeAsync(() => {
      let callCount = 0;
      const operation = jasmine.createSpy('operation').and.callFake(() => {
        callCount++;
        if (callCount < 3) {
          return Promise.reject(new Error('Temporary failure'));
        }
        return Promise.resolve('success');
      });

      let result: string | undefined;
      service.retryOperation(operation, 3, 100)
        .then(res => result = res);

      // Fast-forward through delays
      tick(300); // Total delay for 2 retries

      expect(result).toBe('success');
      expect(operation).toHaveBeenCalledTimes(3);
    }));

    it('should fail after max retries', fakeAsync(() => {
      const operation = jasmine.createSpy('operation').and.returnValue(
        Promise.reject(new Error('Persistent failure'))
      );

      let error: Error | undefined;
      service.retryOperation(operation, 2, 100)
        .catch(err => error = err);

      tick(300); // Total delay for retries

      expect(error).toBeDefined();
      expect(error?.message).toContain('Operation failed after 2 attempts');
      expect(operation).toHaveBeenCalledTimes(2);
    }));
  });
});

interface UserData {
  user: User;
  profile: Profile;
  permissions: Permission[];
}

interface UserUpdate {
  userId: number;
  data: Partial<User>;
}

interface BatchUpdateResult {
  successful: User[];
  failed: any[];
  totalProcessed: number;
}

interface Profile {
  userId: number;
  bio: string;
  avatar: string;
}

interface Permission {
  id: number;
  name: string;
}

Testing Observables and RxJS

Observable Testing Patterns

@Injectable({
  providedIn: 'root'
})
export class ObservableDataService {
  private dataSubject = new BehaviorSubject<Data[]>([]);
  public data$ = this.dataSubject.asObservable();

  private loadingSubject = new BehaviorSubject<boolean>(false);
  public loading$ = this.loadingSubject.asObservable();

  private errorSubject = new Subject<string>();
  public error$ = this.errorSubject.asObservable();

  constructor(private http: HttpClient) {}

  loadData(): Observable<Data[]> {
    this.loadingSubject.next(true);

    return this.http.get<Data[]>('/api/data').pipe(
      tap(data => {
        this.dataSubject.next(data);
        this.loadingSubject.next(false);
      }),
      catchError(error => {
        this.errorSubject.next(error.message);
        this.loadingSubject.next(false);
        return throwError(error);
      }),
      finalize(() => {
        this.loadingSubject.next(false);
      })
    );
  }

  searchData(searchTerm: string): Observable<Data[]> {
    if (!searchTerm.trim()) {
      return of([]);
    }

    return this.http.get<Data[]>('/api/data/search', {
      params: { q: searchTerm }
    }).pipe(
      debounceTime(300),
      distinctUntilChanged(),
      switchMap(searchTerm =>
        this.http.get<Data[]>('/api/data/search', {
          params: { q: searchTerm }
        })
      ),
      catchError(error => {
        console.error('Search failed:', error);
        return of([]);
      })
    );
  }

  getDataWithPagination(page: number, size: number): Observable<PaginatedData> {
    return this.http.get<PaginatedData>('/api/data', {
      params: { page: page.toString(), size: size.toString() }
    }).pipe(
      map(response => ({
        ...response,
        data: response.data.map(item => ({
          ...item,
          processed: true
        }))
      })),
      shareReplay(1)
    );
  }

  getRealTimeUpdates(): Observable<DataUpdate> {
    return new WebSocketSubject('/ws/data-updates').pipe(
      retry(3),
      catchError(error => {
        console.error('WebSocket error:', error);
        return EMPTY;
      })
    );
  }

  combineDataSources(): Observable<CombinedData> {
    return combineLatest([
      this.data$,
      this.http.get<MetaData>('/api/metadata'),
      this.http.get<Settings>('/api/settings')
    ]).pipe(
      map(([data, metadata, settings]) => ({
        data,
        metadata,
        settings,
        timestamp: new Date()
      })),
      shareReplay(1)
    );
  }
}

// Comprehensive observable testing
describe('ObservableDataService', () => {
  let service: ObservableDataService;
  let httpMock: HttpTestingController;
  let testScheduler: TestScheduler;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [ObservableDataService]
    });

    service = TestBed.inject(ObservableDataService);
    httpMock = TestBed.inject(HttpTestingController);

    testScheduler = new TestScheduler((actual, expected) => {
      expect(actual).toEqual(expected);
    });
  });

  afterEach(() => {
    httpMock.verify();
  });

  describe('loadData', () => {
    it('should update loading state correctly', () => {
      testScheduler.run(({ expectObservable, cold }) => {
        const mockData = [{ id: 1, name: 'Test' }];

        // Start loading
        service.loadData().subscribe();

        // Verify loading state changes
        expectObservable(service.loading$).toBe('a-b', {
          a: true,  // Initially loading
          b: false  // After completion
        });

        // Respond to HTTP request
        const req = httpMock.expectOne('/api/data');
        req.flush(mockData);
      });
    });

    it('should handle errors and update error state', () => {
      testScheduler.run(({ expectObservable, cold }) => {
        let errorEmitted = false;

        service.error$.subscribe(error => {
          errorEmitted = true;
          expect(error).toBe('Http failure response for /api/data: 500 Server Error');
        });

        service.loadData().subscribe({
          error: (error) => {
            expect(error.status).toBe(500);
          }
        });

        const req = httpMock.expectOne('/api/data');
        req.error(new ErrorEvent('Server Error'), { status: 500 });

        expect(errorEmitted).toBe(true);
      });
    });

    it('should update data subject with loaded data', fakeAsync(() => {
      const mockData = [{ id: 1, name: 'Test' }];
      let receivedData: Data[] = [];

      service.data$.subscribe(data => receivedData = data);
      service.loadData().subscribe();

      const req = httpMock.expectOne('/api/data');
      req.flush(mockData);
      tick();

      expect(receivedData).toEqual(mockData);
    }));
  });

  describe('searchData', () => {
    it('should return empty array for empty search term', () => {
      testScheduler.run(({ expectObservable }) => {
        const result$ = service.searchData('   ');
        expectObservable(result$).toBe('(a|)', { a: [] });
      });
    });

    it('should debounce search requests', () => {
      testScheduler.run(({ cold, expectObservable }) => {
        const searchTerm = 'test';
        const mockResults = [{ id: 1, name: 'Test Result' }];

        // Simulate rapid search calls
        cold('a-b-c|', { a: 'te', b: 'tes', c: 'test' })
          .pipe(
            mergeMap(term => service.searchData(term))
          )
          .subscribe();

        // Only the last search should result in HTTP request
        const req = httpMock.expectOne('/api/data/search?q=test');
        req.flush(mockResults);

        // No requests for 'te' and 'tes'
        httpMock.expectNone('/api/data/search?q=te');
        httpMock.expectNone('/api/data/search?q=tes');
      });
    });

    it('should handle search errors gracefully', () => {
      testScheduler.run(({ expectObservable }) => {
        const result$ = service.searchData('test');

        expectObservable(result$).toBe('(a|)', { a: [] });

        const req = httpMock.expectOne('/api/data/search?q=test');
        req.error(new ErrorEvent('Search failed'));
      });
    });
  });

  describe('getDataWithPagination', () => {
    it('should transform data and add processed flag', () => {
      const mockResponse: PaginatedData = {
        data: [{ id: 1, name: 'Test' }],
        totalItems: 1,
        currentPage: 1,
        totalPages: 1
      };

      service.getDataWithPagination(1, 10).subscribe(result => {
        expect(result.data[0].processed).toBe(true);
        expect(result.totalItems).toBe(1);
      });

      const req = httpMock.expectOne('/api/data?page=1&size=10');
      req.flush(mockResponse);
    });

    it('should share replay for multiple subscribers', () => {
      const mockResponse: PaginatedData = {
        data: [{ id: 1, name: 'Test' }],
        totalItems: 1,
        currentPage: 1,
        totalPages: 1
      };

      const data$ = service.getDataWithPagination(1, 10);

      // Multiple subscriptions
      data$.subscribe();
      data$.subscribe();
      data$.subscribe();

      // Should only make one HTTP request
      const req = httpMock.expectOne('/api/data?page=1&size=10');
      req.flush(mockResponse);
    });
  });

  describe('combineDataSources', () => {
    it('should combine multiple data sources', fakeAsync(() => {
      const mockData = [{ id: 1, name: 'Test' }];
      const mockMetadata = { version: '1.0', lastUpdated: new Date() };
      const mockSettings = { theme: 'dark', notifications: true };

      let combinedResult: CombinedData | undefined;

      service.combineDataSources().subscribe(result => {
        combinedResult = result;
      });

      // Set initial data
      service['dataSubject'].next(mockData);
      tick();

      // Respond to HTTP requests
      const metadataReq = httpMock.expectOne('/api/metadata');
      const settingsReq = httpMock.expectOne('/api/settings');

      metadataReq.flush(mockMetadata);
      settingsReq.flush(mockSettings);
      tick();

      expect(combinedResult).toBeDefined();
      expect(combinedResult!.data).toEqual(mockData);
      expect(combinedResult!.metadata).toEqual(mockMetadata);
      expect(combinedResult!.settings).toEqual(mockSettings);
      expect(combinedResult!.timestamp).toBeInstanceOf(Date);
    }));
  });

  describe('Observable marble testing', () => {
    it('should test complex observable flows with marble diagrams', () => {
      testScheduler.run(({ cold, hot, expectObservable }) => {
        // Simulate user input stream
        const input$ = cold('a-b-c-d-e|', {
          a: 'h',
          b: 'he',
          c: 'hel',
          d: 'hell',
          e: 'hello'
        });

        // Simulate API response stream
        const apiResponse$ = cold('---x|', { x: ['hello world'] });

        // Test the search flow
        const result$ = input$.pipe(
          debounceTime(2, testScheduler),
          distinctUntilChanged(),
          switchMap(() => apiResponse$)
        );

        // Expected result with marble diagram
        expectObservable(result$).toBe('--------x|', { x: ['hello world'] });
      });
    });
  });
});

interface Data {
  id: number;
  name: string;
  processed?: boolean;
}

interface PaginatedData {
  data: Data[];
  totalItems: number;
  currentPage: number;
  totalPages: number;
}

interface DataUpdate {
  type: 'create' | 'update' | 'delete';
  data: Data;
}

interface CombinedData {
  data: Data[];
  metadata: MetaData;
  settings: Settings;
  timestamp: Date;
}

interface MetaData {
  version: string;
  lastUpdated: Date;
}

interface Settings {
  theme: string;
  notifications: boolean;
}

Summary

This advanced testing module covered comprehensive testing strategies for Angular applications:

  • Testing Architecture: Pyramid structure, test data factories, and base test utilities
  • Unit Testing: Complex component testing with dependencies and interactions
  • Integration Testing: Component communication and interaction flows
  • Async Testing: Promises, async/await, and error handling
  • Observable Testing: RxJS operators, marble testing, and reactive patterns
  • Service Testing: HTTP interactions, caching, and state management

Next Steps

  • Explore visual regression testing
  • Learn about contract testing with Pact
  • Study mutation testing concepts
  • Practice performance testing for Angular apps
  • Investigate accessibility testing automation

Additional Resources