Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

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. A common question from developers new to testing: “Why bother? I can just check it in the browser.” The answer becomes obvious when your app grows past a few dozen components. Without tests, every change becomes a game of whack-a-mole — fixing a bug in the checkout flow silently breaks the user profile page, and you do not find out until a customer reports it. Tests are the safety net that lets you refactor, upgrade dependencies, and add features with confidence. 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: 'alice@example.com' },
        { id: 2, name: 'Bob', email: 'bob@example.com' }
      ];
      
      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: 'charlie@example.com' };
      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: 'alice@example.com'
  };
  
  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('alice@example.com');
  }));
  
  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

Spy objects are fake implementations of your services that let you control what they return and verify how they were called. They are the backbone of isolated unit testing — you replace real dependencies (which would make HTTP calls, access databases, etc.) with spies that return predictable data instantly.
// Create spy object with methods.
// The first argument is just a name for error messages.
// The array lists the methods you want to spy on.
const userServiceSpy = jasmine.createSpyObj('UserService', [
  'getUser', 
  'updateUser',
  'deleteUser'
]);

// Configure return values -- what should the fake service return?
userServiceSpy.getUser.and.returnValue(of(mockUser));
userServiceSpy.updateUser.and.returnValue(of(updatedUser));
userServiceSpy.deleteUser.and.returnValue(of(void 0));

// Verify interactions -- was the service called correctly?
expect(userServiceSpy.getUser).toHaveBeenCalled();
expect(userServiceSpy.getUser).toHaveBeenCalledWith(1);
expect(userServiceSpy.getUser).toHaveBeenCalledTimes(1);
Common pitfall: Forgetting to set up return values before the component calls the spy. If your component calls userService.getUser() during ngOnInit, you must configure userServiceSpy.getUser.and.returnValue(...) before calling fixture.detectChanges() — because detectChanges triggers ngOnInit.

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('user@example.com');
    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('wrong@example.com');
    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('admin@example.com');
    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('test@example.com');
    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

Interview Deep-Dive

Strong Answer: I would not try to retroactively write tests for all 200 components. Instead, I apply the “test on change” strategy: any code you touch gets tests. This is pragmatic and ensures testing effort is proportional to risk — the code you are modifying is the code most likely to break.For prioritization of the initial testing push, I focus on three categories. First, critical user flows: login, checkout, payment, any flow where a bug costs money or users. I write E2E tests for these using Playwright — they give the highest confidence with the least effort for existing code. Second, shared services and utilities: these are used everywhere, so a bug here has blast radius across the entire app. Unit tests for services are fast to write and high-value. Third, complex business logic: any code with conditional branching, calculations, or state machines.Going forward, I implement a testing policy: all new code requires tests, all bug fixes require a regression test (a test that fails before the fix and passes after). I configure code coverage thresholds that only apply to new/changed files, not the entire codebase, so the team does not feel paralyzed by the 0% baseline.For the test distribution, I aim for 70% unit tests (services, pipes, pure functions), 20% component integration tests (TestBed with real child components), and 10% E2E tests (critical flows). I avoid testing implementation details — I test behavior, not internal state.Follow-up: How do you decide between unit testing a component in isolation versus integration testing it with its children? Answer: If the component is presentational (only inputs/outputs, no services), I test it in isolation — set inputs, check rendered output, click buttons, verify emitted events. If it is a smart/container component that orchestrates children, I test with real children where possible, using stub components only for children with complex dependencies. The reason: testing with real children catches integration bugs (wrong input name, mismatched event type) that isolated tests miss.
Strong Answer: fakeAsync creates a synchronous test zone where time is under your control. It intercepts all async operations (setTimeout, setInterval, Promise.then) and queues them instead of executing them. tick(milliseconds) advances the virtual clock by the specified amount, executing any queued operations whose timer has elapsed. flush() runs all pending async operations regardless of their scheduled time.Under the hood, fakeAsync replaces the real browser timing APIs with virtual ones within the test zone. This is why you can write linear, synchronous-looking test code that actually tests asynchronous behavior: set up the state, call tick(1000), assert the result.waitForAsync (formerly async) is different — it runs the test body in a real async zone and waits for all async operations to complete naturally. You call fixture.whenStable() to wait, and the test resolves when all promises and observables settle. The downside: you cannot control timing. If a debounce is 300ms, you actually wait 300ms. For fast tests, fakeAsync with tick(300) is instant.I use fakeAsync for 90% of async tests because it is deterministic and fast. I use waitForAsync when I need to test real async behavior that fakeAsync cannot intercept — for example, actual HTTP calls in integration tests (rare), or when testing zone-related behavior itself. I use the done callback when testing plain Observables outside of Angular’s zone.Follow-up: What is the gotcha with fakeAsync and HTTP requests? Answer: fakeAsync with HttpTestingController works perfectly because you control when the response arrives (via req.flush). But fakeAsync with REAL HTTP requests does not work — fakeAsync cannot intercept actual XHR/fetch calls. That is why Angular testing always uses HttpClientTestingModule: it replaces the real HTTP backend with a mock that you control synchronously within the fakeAsync zone.
Strong Answer: Signal-based components are actually easier to test than observable-based ones. Signals are synchronous — when you call component.count.set(5), the value is immediately available. You do not need fakeAsync, tick, or subscribe. You just set the signal and assert: expect(component.count()).toBe(5). Computed signals also update synchronously: after setting count to 5, component.doubled() immediately returns 10.For testing signal inputs, use fixture.componentRef.setInput(‘name’, ‘value’), which is the modern way to set inputs in tests. This works for both decorator-based and signal-based inputs. Then call fixture.detectChanges() to update the template.Effects are the one area where testing gets tricky. Effects are scheduled to run asynchronously (on the microtask queue). In tests, you need to trigger change detection (fixture.detectChanges()) or use TestBed.flushEffects() to ensure effects have run before asserting their side effects. For example, if an effect syncs to localStorage, you need to flush effects before checking localStorage.The key difference from observable testing: no subscription management, no async pipe concerns, no fakeAsync needed for most cases. You set signals, detect changes, and assert DOM state. It is closer to testing plain JavaScript objects than testing reactive streams.Follow-up: How do you test a component that uses toSignal internally to convert an HTTP observable to a signal? Answer: You still mock the HTTP service (or use HttpTestingController) the same way. The toSignal call subscribes on component creation, so when TestBed creates the component, it subscribes to the mocked observable. If the mock returns of(data), the signal has the value immediately. If you need to control timing, use a Subject as the mock return value — you can call subject.next(data) at the exact moment you want in the test, then call fixture.detectChanges() to see the template update.

Next Steps

Next: Advanced Patterns

Learn advanced architectural patterns for large applications