Module Overview
Estimated Time: 4-5 hours | Difficulty: Intermediate | Prerequisites: Module 10
- Unit testing services
- Component testing strategies
- Testing with TestBed
- Mocking dependencies
- Async testing patterns
- E2E testing with Playwright
Testing Pyramid
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ Testing Pyramid │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ /\ │
│ / \ E2E Tests │
│ / \ • Full app testing │
│ / E2E \ • Slow, expensive │
│ /________\ • Few tests │
│ / \ │
│ / \ Integration Tests │
│ / Integration \ • Component + deps │
│ /________________\ • Medium speed │
│ / \ │
│ / Unit Tests \ Unit Tests │
│ /______________________\• Isolated logic │
│ • Fast, cheap │
│ • Many tests │
│ │
│ Recommended Distribution: │
│ • 70% Unit Tests │
│ • 20% Integration Tests │
│ • 10% E2E Tests │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Unit Testing Services
Simple Service Test
Copy
// calculator.service.ts
@Injectable({ providedIn: 'root' })
export class CalculatorService {
add(a: number, b: number): number {
return a + b;
}
divide(a: number, b: number): number {
if (b === 0) throw new Error('Cannot divide by zero');
return a / b;
}
}
// calculator.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { CalculatorService } from './calculator.service';
describe('CalculatorService', () => {
let service: CalculatorService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CalculatorService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('add', () => {
it('should add two positive numbers', () => {
expect(service.add(2, 3)).toBe(5);
});
it('should handle negative numbers', () => {
expect(service.add(-1, -2)).toBe(-3);
});
it('should handle zero', () => {
expect(service.add(5, 0)).toBe(5);
});
});
describe('divide', () => {
it('should divide two numbers', () => {
expect(service.divide(10, 2)).toBe(5);
});
it('should throw error when dividing by zero', () => {
expect(() => service.divide(10, 0)).toThrowError('Cannot divide by zero');
});
});
});
Service with HTTP
Copy
// user.service.ts
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
private apiUrl = '/api/users';
getUsers(): Observable<User[]> {
return this.http.get<User[]>(this.apiUrl);
}
createUser(user: CreateUserDto): Observable<User> {
return this.http.post<User>(this.apiUrl, user);
}
}
// user.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { UserService } from './user.service';
describe('UserService', () => {
let service: UserService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [UserService]
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // Verify no outstanding requests
});
describe('getUsers', () => {
it('should return users from API', () => {
const mockUsers: User[] = [
{ id: 1, name: 'Alice', email: '[email protected]' },
{ id: 2, name: 'Bob', email: '[email protected]' }
];
service.getUsers().subscribe(users => {
expect(users.length).toBe(2);
expect(users).toEqual(mockUsers);
});
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('GET');
req.flush(mockUsers);
});
it('should handle error response', () => {
service.getUsers().subscribe({
next: () => fail('should have failed'),
error: (error) => {
expect(error.status).toBe(500);
}
});
const req = httpMock.expectOne('/api/users');
req.flush('Server error', { status: 500, statusText: 'Server Error' });
});
});
describe('createUser', () => {
it('should send POST request with user data', () => {
const newUser = { name: 'Charlie', email: '[email protected]' };
const createdUser = { id: 3, ...newUser };
service.createUser(newUser).subscribe(user => {
expect(user).toEqual(createdUser);
});
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(newUser);
req.flush(createdUser);
});
});
});
Component Testing
Simple Component Test
Copy
// greeting.component.ts
@Component({
selector: 'app-greeting',
standalone: true,
template: `
<h1>Hello, {{ name() }}!</h1>
<button (click)="greet()">Greet</button>
`
})
export class GreetingComponent {
name = input('World');
greeted = output<string>();
greet() {
this.greeted.emit(this.name());
}
}
// greeting.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GreetingComponent } from './greeting.component';
describe('GreetingComponent', () => {
let component: GreetingComponent;
let fixture: ComponentFixture<GreetingComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GreetingComponent]
}).compileComponents();
fixture = TestBed.createComponent(GreetingComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display default name', () => {
const h1 = fixture.nativeElement.querySelector('h1');
expect(h1.textContent).toContain('Hello, World!');
});
it('should display custom name', () => {
fixture.componentRef.setInput('name', 'Angular');
fixture.detectChanges();
const h1 = fixture.nativeElement.querySelector('h1');
expect(h1.textContent).toContain('Hello, Angular!');
});
it('should emit greeted event when button clicked', () => {
const spy = jasmine.createSpy('greeted');
component.greeted.subscribe(spy);
const button = fixture.nativeElement.querySelector('button');
button.click();
expect(spy).toHaveBeenCalledWith('World');
});
});
Component with Dependencies
Copy
// user-profile.component.ts
@Component({
selector: 'app-user-profile',
standalone: true,
template: `
@if (loading()) {
<div class="loading">Loading...</div>
} @else if (user()) {
<div class="profile">
<h2>{{ user()!.name }}</h2>
<p>{{ user()!.email }}</p>
</div>
} @else {
<div class="error">User not found</div>
}
`
})
export class UserProfileComponent implements OnInit {
private userService = inject(UserService);
private route = inject(ActivatedRoute);
user = signal<User | null>(null);
loading = signal(true);
ngOnInit() {
const id = this.route.snapshot.params['id'];
this.userService.getUser(id).subscribe({
next: (user) => {
this.user.set(user);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
}
});
}
}
// user-profile.component.spec.ts
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { of, throwError, delay } from 'rxjs';
import { UserProfileComponent } from './user-profile.component';
import { UserService } from '../services/user.service';
describe('UserProfileComponent', () => {
let component: UserProfileComponent;
let fixture: ComponentFixture<UserProfileComponent>;
let userServiceSpy: jasmine.SpyObj<UserService>;
const mockUser: User = {
id: 1,
name: 'Alice',
email: '[email protected]'
};
beforeEach(async () => {
userServiceSpy = jasmine.createSpyObj('UserService', ['getUser']);
await TestBed.configureTestingModule({
imports: [UserProfileComponent],
providers: [
{ provide: UserService, useValue: userServiceSpy },
{
provide: ActivatedRoute,
useValue: { snapshot: { params: { id: '1' } } }
}
]
}).compileComponents();
});
function createComponent() {
fixture = TestBed.createComponent(UserProfileComponent);
component = fixture.componentInstance;
fixture.detectChanges();
}
it('should show loading state initially', () => {
userServiceSpy.getUser.and.returnValue(of(mockUser).pipe(delay(100)));
createComponent();
const loading = fixture.nativeElement.querySelector('.loading');
expect(loading).toBeTruthy();
expect(loading.textContent).toContain('Loading...');
});
it('should display user profile when loaded', fakeAsync(() => {
userServiceSpy.getUser.and.returnValue(of(mockUser));
createComponent();
tick();
fixture.detectChanges();
const profile = fixture.nativeElement.querySelector('.profile');
expect(profile).toBeTruthy();
expect(profile.textContent).toContain('Alice');
expect(profile.textContent).toContain('[email protected]');
}));
it('should show error when user not found', fakeAsync(() => {
userServiceSpy.getUser.and.returnValue(throwError(() => new Error('Not found')));
createComponent();
tick();
fixture.detectChanges();
const error = fixture.nativeElement.querySelector('.error');
expect(error).toBeTruthy();
expect(error.textContent).toContain('User not found');
}));
it('should call userService with route param', () => {
userServiceSpy.getUser.and.returnValue(of(mockUser));
createComponent();
expect(userServiceSpy.getUser).toHaveBeenCalledWith('1');
});
});
Testing Utilities
Query Elements
Copy
describe('Component Queries', () => {
let fixture: ComponentFixture<MyComponent>;
// By CSS selector
const getButton = () => fixture.nativeElement.querySelector('button');
const getAllItems = () => fixture.nativeElement.querySelectorAll('.item');
// By directive
const getChildComponent = () =>
fixture.debugElement.query(By.directive(ChildComponent));
// By CSS (DebugElement)
const getHeader = () =>
fixture.debugElement.query(By.css('h1'));
// Trigger events
it('should handle click', () => {
const button = getButton();
button.click(); // Native click
fixture.detectChanges();
});
it('should handle input', () => {
const input = fixture.nativeElement.querySelector('input');
input.value = 'test';
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
});
});
Async Testing
Copy
import { fakeAsync, tick, flush, waitForAsync } from '@angular/core/testing';
describe('Async Tests', () => {
// fakeAsync + tick for synchronous-style async testing
it('should handle setTimeout', fakeAsync(() => {
let value = false;
setTimeout(() => value = true, 1000);
expect(value).toBeFalse();
tick(1000);
expect(value).toBeTrue();
}));
// flush() - complete all pending async tasks
it('should complete all timers', fakeAsync(() => {
let count = 0;
setTimeout(() => count++, 100);
setTimeout(() => count++, 200);
setTimeout(() => count++, 300);
flush();
expect(count).toBe(3);
}));
// waitForAsync for real async (Promises)
it('should handle Promise', waitForAsync(() => {
let result: string;
Promise.resolve('done').then(r => result = r);
fixture.whenStable().then(() => {
expect(result).toBe('done');
});
}));
// done callback
it('should call done when complete', (done) => {
service.getData().subscribe({
next: (data) => {
expect(data).toBeTruthy();
done();
},
error: done.fail
});
});
});
Mocking Strategies
Spy Objects
Copy
// Create spy object with methods
const userServiceSpy = jasmine.createSpyObj('UserService', [
'getUser',
'updateUser',
'deleteUser'
]);
// Configure return values
userServiceSpy.getUser.and.returnValue(of(mockUser));
userServiceSpy.updateUser.and.returnValue(of(updatedUser));
userServiceSpy.deleteUser.and.returnValue(of(void 0));
// Check calls
expect(userServiceSpy.getUser).toHaveBeenCalled();
expect(userServiceSpy.getUser).toHaveBeenCalledWith(1);
expect(userServiceSpy.getUser).toHaveBeenCalledTimes(1);
Mock Class
Copy
// Create mock class
class MockUserService {
getUser = jasmine.createSpy('getUser').and.returnValue(of(mockUser));
users$ = new BehaviorSubject<User[]>([]);
refreshUsers() {
this.users$.next([mockUser]);
}
}
// Use in TestBed
TestBed.configureTestingModule({
providers: [
{ provide: UserService, useClass: MockUserService }
]
});
Stub Component
Copy
// Instead of importing real component with complex dependencies
@Component({
selector: 'app-complex-child',
standalone: true,
template: '<div>Stub</div>'
})
class StubComplexChildComponent {
@Input() data: any;
@Output() action = new EventEmitter<void>();
}
TestBed.configureTestingModule({
imports: [ParentComponent, StubComplexChildComponent]
}).overrideComponent(ParentComponent, {
remove: { imports: [ComplexChildComponent] },
add: { imports: [StubComplexChildComponent] }
});
Testing Signals
Copy
// counter.component.ts
@Component({
selector: 'app-counter',
standalone: true,
template: `
<span class="count">{{ count() }}</span>
<span class="doubled">{{ doubled() }}</span>
<button (click)="increment()">+</button>
`
})
export class CounterComponent {
count = signal(0);
doubled = computed(() => this.count() * 2);
increment() {
this.count.update(c => c + 1);
}
}
// counter.component.spec.ts
describe('CounterComponent with Signals', () => {
let component: CounterComponent;
let fixture: ComponentFixture<CounterComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CounterComponent]
}).compileComponents();
fixture = TestBed.createComponent(CounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should have initial count of 0', () => {
expect(component.count()).toBe(0);
});
it('should compute doubled value', () => {
expect(component.doubled()).toBe(0);
component.count.set(5);
expect(component.doubled()).toBe(10);
});
it('should increment count when button clicked', () => {
const button = fixture.nativeElement.querySelector('button');
button.click();
fixture.detectChanges();
expect(component.count()).toBe(1);
expect(fixture.nativeElement.querySelector('.count').textContent).toBe('1');
});
it('should update doubled when count changes', () => {
component.increment();
component.increment();
fixture.detectChanges();
const doubled = fixture.nativeElement.querySelector('.doubled');
expect(doubled.textContent).toBe('4');
});
});
E2E Testing with Playwright
Copy
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Login Page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
});
test('should display login form', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible();
await expect(page.getByLabel('Email')).toBeVisible();
await expect(page.getByLabel('Password')).toBeVisible();
await expect(page.getByRole('button', { name: 'Sign In' })).toBeVisible();
});
test('should show validation errors', async ({ page }) => {
await page.getByRole('button', { name: 'Sign In' }).click();
await expect(page.getByText('Email is required')).toBeVisible();
await expect(page.getByText('Password is required')).toBeVisible();
});
test('should login successfully with valid credentials', async ({ page }) => {
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign In' }).click();
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
test('should show error for invalid credentials', async ({ page }) => {
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('wrongpassword');
await page.getByRole('button', { name: 'Sign In' }).click();
await expect(page.getByText('Invalid email or password')).toBeVisible();
await expect(page).toHaveURL('/login');
});
});
// e2e/user-flow.spec.ts
test.describe('User CRUD Flow', () => {
test('should create, read, update, and delete user', async ({ page }) => {
// Login first
await page.goto('/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('admin123');
await page.getByRole('button', { name: 'Sign In' }).click();
await expect(page).toHaveURL('/dashboard');
// Navigate to users
await page.getByRole('link', { name: 'Users' }).click();
await expect(page).toHaveURL('/users');
// Create user
await page.getByRole('button', { name: 'Add User' }).click();
await page.getByLabel('Name').fill('Test User');
await page.getByLabel('Email').fill('[email protected]');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('User created successfully')).toBeVisible();
await expect(page.getByText('Test User')).toBeVisible();
// Update user
await page.getByRole('row', { name: /Test User/ })
.getByRole('button', { name: 'Edit' }).click();
await page.getByLabel('Name').fill('Updated User');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('User updated successfully')).toBeVisible();
await expect(page.getByText('Updated User')).toBeVisible();
// Delete user
await page.getByRole('row', { name: /Updated User/ })
.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByText('User deleted successfully')).toBeVisible();
await expect(page.getByText('Updated User')).not.toBeVisible();
});
});
Code Coverage
Copy
# Run tests with coverage
ng test --code-coverage
# Coverage report at coverage/index.html
# Configure in karma.conf.js or angular.json
{
"test": {
"options": {
"codeCoverage": true,
"codeCoverageExclude": [
"src/app/**/*.spec.ts",
"src/app/**/*.mock.ts"
]
}
}
}
Practice Exercise
Exercise: Test a Shopping Cart Service
Write comprehensive tests for a CartService that:
- Adds/removes items
- Updates quantities
- Calculates totals
- Persists to localStorage
- Has async operations
Solution
Solution
Copy
// cart.service.spec.ts
describe('CartService', () => {
let service: CartService;
let localStorageSpy: jasmine.SpyObj<Storage>;
const mockProduct: Product = {
id: 1,
name: 'Widget',
price: 25.00
};
beforeEach(() => {
localStorageSpy = jasmine.createSpyObj('localStorage', ['getItem', 'setItem']);
localStorageSpy.getItem.and.returnValue(null);
spyOnProperty(window, 'localStorage').and.returnValue(localStorageSpy);
TestBed.configureTestingModule({
providers: [CartService]
});
service = TestBed.inject(CartService);
});
describe('initialization', () => {
it('should start with empty cart', () => {
expect(service.items()).toEqual([]);
expect(service.itemCount()).toBe(0);
expect(service.total()).toBe(0);
});
it('should load cart from localStorage', () => {
const savedCart = [{ productId: 1, name: 'Widget', price: 25, quantity: 2 }];
localStorageSpy.getItem.and.returnValue(JSON.stringify(savedCart));
// Recreate service to trigger load
service = TestBed.inject(CartService);
expect(service.items().length).toBe(1);
expect(service.items()[0].quantity).toBe(2);
});
});
describe('addItem', () => {
it('should add new item to cart', () => {
service.addItem(mockProduct);
expect(service.items().length).toBe(1);
expect(service.items()[0].productId).toBe(1);
expect(service.items()[0].quantity).toBe(1);
});
it('should increment quantity for existing item', () => {
service.addItem(mockProduct);
service.addItem(mockProduct);
expect(service.items().length).toBe(1);
expect(service.items()[0].quantity).toBe(2);
});
it('should persist to localStorage', () => {
service.addItem(mockProduct);
expect(localStorageSpy.setItem).toHaveBeenCalledWith(
'cart',
jasmine.any(String)
);
});
});
describe('removeItem', () => {
it('should remove item from cart', () => {
service.addItem(mockProduct);
service.removeItem(1);
expect(service.items().length).toBe(0);
});
it('should not throw for non-existent item', () => {
expect(() => service.removeItem(999)).not.toThrow();
});
});
describe('updateQuantity', () => {
beforeEach(() => {
service.addItem(mockProduct);
});
it('should update item quantity', () => {
service.updateQuantity(1, 5);
expect(service.items()[0].quantity).toBe(5);
});
it('should remove item if quantity is 0 or less', () => {
service.updateQuantity(1, 0);
expect(service.items().length).toBe(0);
});
});
describe('computed values', () => {
it('should calculate itemCount correctly', () => {
service.addItem(mockProduct);
service.addItem({ id: 2, name: 'Gadget', price: 50 });
service.updateQuantity(1, 3);
expect(service.itemCount()).toBe(4); // 3 + 1
});
it('should calculate total correctly', () => {
service.addItem(mockProduct); // 25
service.addItem({ id: 2, name: 'Gadget', price: 50 }); // 50
service.updateQuantity(1, 2); // 25 * 2 = 50
expect(service.total()).toBe(100); // 50 + 50
});
});
describe('clear', () => {
it('should empty the cart', () => {
service.addItem(mockProduct);
service.addItem({ id: 2, name: 'Gadget', price: 50 });
service.clear();
expect(service.items().length).toBe(0);
expect(service.total()).toBe(0);
});
});
});
Summary
1
Unit Tests
Test isolated logic in services with mocked dependencies
2
Component Tests
Use TestBed to test component rendering and behavior
3
Async Testing
Use fakeAsync/tick for timers, waitForAsync for Promises
4
Mocking
Use jasmine.createSpyObj and mock classes for dependencies
5
E2E Tests
Use Playwright for full user flow testing
Next Steps
Next: Advanced Patterns
Learn advanced architectural patterns for large applications