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