Advanced Topics

Testing Angular Applications

Learn how to write unit tests, component tests, and integration tests for Angular apps using Jasmine, Karma, and TestBed.

Why Test?

Testing ensures your Angular application works correctly, catches bugs early, and gives you confidence when refactoring. Angular ships with a complete testing setup out of the box.

Testing Stack

ToolPurpose
JasmineTest framework (describe, it, expect)
KarmaTest runner (executes tests in a browser)
TestBedAngular testing utility for configuring test modules
Angular CLIng test command to run tests

Running Tests

bash
# Run tests (opens browser, watches for changes)
ng test

# Run once without watching
ng test --watch=false

# Run with code coverage report
ng test --code-coverage

# Run specific test file
ng test --include=src/app/services/user.service.spec.ts

Testing Services

Simple Service Test

typescript
// 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('Division by zero');
    return a / b;
  }
}
typescript
// calculator.service.spec.ts
describe('CalculatorService', () => {
  let service: CalculatorService;

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

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  it('should add two numbers', () => {
    expect(service.add(2, 3)).toBe(5);
    expect(service.add(-1, 1)).toBe(0);
  });

  it('should divide two numbers', () => {
    expect(service.divide(10, 2)).toBe(5);
  });

  it('should throw on division by zero', () => {
    expect(() => service.divide(10, 0)).toThrowError('Division by zero');
  });
});

Service with HTTP Dependency

typescript
// user.service.ts
@Injectable({ providedIn: 'root' })
export class UserService {
  private http = inject(HttpClient);

  getUsers(): Observable<User[]> {
    return this.http.get<User[]>('/api/users');
  }

  getUser(id: number): Observable<User> {
    return this.http.get<User>(`/api/users/${id}`);
  }
}
typescript
// user.service.spec.ts
describe('UserService', () => {
  let service: UserService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
    });
    service = TestBed.inject(UserService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpMock.verify(); // Ensure no outstanding requests
  });

  it('should fetch all users', () => {
    const mockUsers: User[] = [
      { id: 1, name: 'Alice', email: 'alice@test.com' },
      { id: 2, name: 'Bob', email: 'bob@test.com' },
    ];

    service.getUsers().subscribe(users => {
      expect(users.length).toBe(2);
      expect(users[0].name).toBe('Alice');
    });

    const req = httpMock.expectOne('/api/users');
    expect(req.request.method).toBe('GET');
    req.flush(mockUsers); // Respond with mock data
  });

  it('should handle HTTP error', () => {
    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: 'Internal Server Error' });
  });
});

Testing Components

Basic Component Test

typescript
// greeting.component.ts
@Component({
  standalone: true,
  template: `<h1>Hello, {{ name }}!</h1>`,
})
export class GreetingComponent {
  @Input() name = 'World';
}
typescript
// greeting.component.spec.ts
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(); // Trigger initial change detection
  });

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

  it('should display default greeting', () => {
    const h1 = fixture.nativeElement.querySelector('h1');
    expect(h1.textContent).toBe('Hello, World!');
  });

  it('should display custom name', () => {
    component.name = 'Angular';
    fixture.detectChanges();
    const h1 = fixture.nativeElement.querySelector('h1');
    expect(h1.textContent).toBe('Hello, Angular!');
  });
});

Component with User Interaction

typescript
// counter.component.spec.ts
describe('CounterComponent', () => {
  let fixture: ComponentFixture<CounterComponent>;
  let component: CounterComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [CounterComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(CounterComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should start at 0', () => {
    const display = fixture.nativeElement.querySelector('.count');
    expect(display.textContent).toContain('0');
  });

  it('should increment on button click', () => {
    const button = fixture.nativeElement.querySelector('.increment');
    button.click();
    fixture.detectChanges();

    const display = fixture.nativeElement.querySelector('.count');
    expect(display.textContent).toContain('1');
  });

  it('should emit countChange event', () => {
    spyOn(component.countChange, 'emit');
    component.increment();
    expect(component.countChange.emit).toHaveBeenCalledWith(1);
  });
});

Component with Injected Service

typescript
// user-list.component.spec.ts
describe('UserListComponent', () => {
  let fixture: ComponentFixture<UserListComponent>;
  let userServiceSpy: jasmine.SpyObj<UserService>;

  beforeEach(async () => {
    // Create a spy/mock for the service
    const spy = jasmine.createSpyObj('UserService', ['getUsers']);

    await TestBed.configureTestingModule({
      imports: [UserListComponent],
      providers: [
        { provide: UserService, useValue: spy },
      ],
    }).compileComponents();

    userServiceSpy = TestBed.inject(UserService) as jasmine.SpyObj<UserService>;
  });

  it('should display users when loaded', () => {
    const mockUsers = [
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' },
    ];
    userServiceSpy.getUsers.and.returnValue(of(mockUsers));

    fixture = TestBed.createComponent(UserListComponent);
    fixture.detectChanges();

    const items = fixture.nativeElement.querySelectorAll('.user-item');
    expect(items.length).toBe(2);
    expect(items[0].textContent).toContain('Alice');
  });

  it('should show loading state', () => {
    userServiceSpy.getUsers.and.returnValue(new Observable()); // Never emits

    fixture = TestBed.createComponent(UserListComponent);
    fixture.detectChanges();

    const loading = fixture.nativeElement.querySelector('.loading');
    expect(loading).toBeTruthy();
  });
});

Testing Pipes

typescript
// truncate.pipe.ts
@Pipe({ name: 'truncate', standalone: true })
export class TruncatePipe implements PipeTransform {
  transform(value: string, limit: number = 50): string {
    return value.length > limit ? value.substring(0, limit) + '...' : value;
  }
}
typescript
// truncate.pipe.spec.ts
describe('TruncatePipe', () => {
  const pipe = new TruncatePipe();

  it('should not truncate short strings', () => {
    expect(pipe.transform('Hello')).toBe('Hello');
  });

  it('should truncate long strings with default limit', () => {
    const long = 'A'.repeat(60);
    const result = pipe.transform(long);
    expect(result.length).toBe(53); // 50 + '...'
    expect(result.endsWith('...')).toBeTrue();
  });

  it('should truncate with custom limit', () => {
    expect(pipe.transform('Hello World', 5)).toBe('Hello...');
  });
});

Testing with Async Operations

fakeAsync / tick

typescript
it('should debounce search input', fakeAsync(() => {
  component.searchControl.setValue('ang');
  tick(200); // Advance 200ms (debounce is 300ms)

  expect(searchServiceSpy.search).not.toHaveBeenCalled();

  tick(100); // Advance to 300ms total
  expect(searchServiceSpy.search).toHaveBeenCalledWith('ang');
}));

waitForAsync

typescript
it('should load data on init', waitForAsync(() => {
  userServiceSpy.getUsers.and.returnValue(of(mockUsers));
  fixture.detectChanges();

  fixture.whenStable().then(() => {
    fixture.detectChanges();
    const items = fixture.nativeElement.querySelectorAll('.user-item');
    expect(items.length).toBe(2);
  });
}));

Common Matchers

typescript
// Equality
expect(value).toBe(5);             // Strict ===
expect(value).toEqual({ a: 1 });   // Deep equality

// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeDefined();
expect(value).toBeUndefined();

// Comparison
expect(value).toBeGreaterThan(3);
expect(value).toBeLessThanOrEqual(10);

// Strings
expect(value).toContain('hello');
expect(value).toMatch(/pattern/);

// Arrays
expect(arr).toContain('item');
expect(arr).toHaveSize(3);

// Negation
expect(value).not.toBe(0);

// Spies
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith('arg1', 'arg2');
expect(spy).toHaveBeenCalledTimes(3);

Best Practices

  1. Test behavior, not implementation — focus on what components do, not internal details
  2. Use beforeEach to set up fresh instances for each test
  3. Mock dependencies — services, HTTP calls, and external dependencies
  4. Keep tests independent — tests should not depend on each other
  5. Use meaningful test descriptionsit('should display error when form is invalid')
  6. Test edge cases — empty arrays, null values, error states
  7. Aim for meaningful coverage — focus on business logic and critical paths

Next Steps

With testing in place, let's explore performance optimization — making your Angular application faster with change detection strategies, lazy loading, and bundle optimization techniques.