Skip to main content
Angular Testing

Module Overview

Estimated Time: 4-5 hours | Difficulty: Intermediate | Prerequisites: Module 10
Testing is essential for maintaining code quality and preventing regressions. Angular provides robust testing utilities that integrate with Jasmine and Karma out of the box, with support for Jest as an alternative. What You’ll Learn:
  • Unit testing services
  • Component testing strategies
  • Testing with TestBed
  • Mocking dependencies
  • Async testing patterns
  • E2E testing with Playwright

Testing Pyramid

┌─────────────────────────────────────────────────────────────────────────┐
│                    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

// 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

// 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

// 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

// 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

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

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

// 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

// 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

// 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

// 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

// 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

# 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:
  1. Adds/removes items
  2. Updates quantities
  3. Calculates totals
  4. Persists to localStorage
  5. Has async operations
// 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