24 Testing in Angular

24.1 Die Testing-Pyramide verstehen

Softwarequalität manifestiert sich nicht in perfektem Code, sondern in verifizierbarem Verhalten. Angular-Anwendungen sind komplex – Komponenten interagieren, Services koordinieren State, HTTP-Requests fliegen asynchron. Ohne automatisierte Tests ist Regression unvermeidlich. Eine kleine Änderung bricht unerwartete Features.

Die Testing-Pyramide strukturiert Tests nach Granularität und Kosten. Unit-Tests bilden die breite Basis – viele, schnell, isoliert. Integrationstests bilden die Mitte – weniger, langsamer, testen Zusammenspiel. E2E-Tests bilden die Spitze – wenige, langsam, teuer, aber realitätsnah.

Angular unterstützt alle drei Ebenen. Jasmine und Karma handhaben Unit- und Integrationstests. Cypress oder Playwright handhaben E2E-Tests. Die Toolchain ist opinionated aber extensible.

Das fundamentale Pattern ist Arrange-Act-Assert. Arrange erstellt Test-State. Act führt die Operation aus. Assert verifiziert das Resultat. Dieses Pattern durchzieht alle Test-Typen, von Unit bis E2E.

24.2 TestBed: Die Testumgebung

Angular’s TestBed ist eine Test-Harness, die Angular’s Dependency Injection und Modul-System simuliert. Es konfiguriert eine isolierte Angular-Umgebung für jeden Test. Komponenten können erstellt, Services injiziert, Change Detection getriggert werden.

Ein minimaler Component-Test:

import { TestBed, ComponentFixture } from '@angular/core/testing';
import { UserComponent } from './user.component';

describe('UserComponent', () => {
  let component: UserComponent;
  let fixture: ComponentFixture<UserComponent>;
  
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [UserComponent]
    }).compileComponents();
    
    fixture = TestBed.createComponent(UserComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });
  
  it('should create', () => {
    expect(component).toBeTruthy();
  });
});

Die configureTestingModule-Methode mirrors @NgModule-Konfiguration. declarations registriert Komponenten, imports lädt Module, providers konfiguriert Services. Die compileComponents-Methode kompiliert Templates asynchron – essentiell bei externen Templates oder Styles.

createComponent instantiiert die Komponente und gibt eine ComponentFixture zurück. Die Fixture ist ein Wrapper um die Component-Instanz mit Testing-Utilities. componentInstance gibt Zugriff auf die Component-Klasse. detectChanges triggert Change Detection manuell.

Die Async-Natur von beforeEach ist wichtig. Template-Kompilierung ist asynchron. Das await stellt sicher, dass Compilation abgeschlossen ist bevor Tests laufen. Ohne async/await würden Tests mit unkompilierten Templates fehlschlagen.

24.2.1 Fixture und Change Detection

Die ComponentFixture kontrolliert Component-Lifecycle und Change Detection:

it('should update view when property changes', () => {
  component.userName = 'John';
  fixture.detectChanges();
  
  const element = fixture.nativeElement;
  const nameElement = element.querySelector('.user-name');
  
  expect(nameElement.textContent).toContain('John');
});

detectChanges() ist explizit nötig. TestBed disabled automatische Change Detection. Dies gibt Tests volle Kontrolle über Timing. Property-Änderung allein updated das DOM nicht – erst detectChanges() propagiert Changes.

nativeElement gibt Zugriff auf das native DOM-Element. Dies ist browser-DOM oder Server-DOM je nach Plattform. Für plattform-agnostische Tests ist DebugElement besser:

import { By } from '@angular/platform-browser';

it('should display user name', () => {
  component.userName = 'Jane';
  fixture.detectChanges();
  
  const debugElement = fixture.debugElement.query(By.css('.user-name'));
  expect(debugElement.nativeElement.textContent).toContain('Jane');
});

DebugElement abstrahiert Plattform-Unterschiede. Die query-Methode nutzt By-Prädikate für type-safe Queries. By.css wählt via CSS-Selector, By.directive wählt via Direktive.

24.2.2 Testing mit Standalone Components

Angular 14+ Standalone Components vereinfachen Test-Setup:

import { TestBed } from '@angular/core/testing';
import { UserComponent } from './user.component';
import { UserService } from './user.service';

describe('UserComponent (Standalone)', () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [UserComponent],
      providers: [UserService]
    }).compileComponents();
  });
  
  it('should work', () => {
    const fixture = TestBed.createComponent(UserComponent);
    expect(fixture.componentInstance).toBeTruthy();
  });
});

Standalone Components werden in imports statt declarations registriert. Die semantische Unterscheidung reflektiert ihre Self-Contained-Natur.

24.3 Services testen

Services sind pure TypeScript-Klassen, oft ohne Angular-spezifische Dependencies. Einfache Services benötigen kein TestBed:

import { CalculatorService } from './calculator.service';

describe('CalculatorService', () => {
  let service: CalculatorService;
  
  beforeEach(() => {
    service = new CalculatorService();
  });
  
  it('should add numbers', () => {
    expect(service.add(2, 3)).toBe(5);
  });
  
  it('should multiply numbers', () => {
    expect(service.multiply(4, 5)).toBe(20);
  });
  
  it('should throw on divide by zero', () => {
    expect(() => service.divide(10, 0)).toThrow();
  });
});

Die manuelle Instantiierung ist schneller als TestBed-Setup. Für Services ohne Dependencies ist dies der bevorzugte Ansatz.

Services mit Dependencies benötigen Dependency Injection:

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();
  });
  
  it('should fetch users', () => {
    const mockUsers = [
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' }
    ];
    
    service.getUsers().subscribe(users => {
      expect(users).toEqual(mockUsers);
    });
    
    const req = httpMock.expectOne('/api/users');
    expect(req.request.method).toBe('GET');
    req.flush(mockUsers);
  });
  
  it('should handle errors', () => {
    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' });
  });
});

Das HttpClientTestingModule mockt HttpClient. Die HttpTestingController kontrolliert HTTP-Requests im Test. Die expectOne-Methode asserted dass genau ein Request gemacht wurde und gibt ein TestRequest-Objekt zurück.

Das req.flush-Call simuliert die Server-Response. Der Test ist synchron obwohl der echte Request asynchron wäre. Dies macht Tests schneller und deterministischer.

Die afterEach-Methode mit httpMock.verify() stellt sicher, dass keine unerwarteten Requests gemacht wurden. Dies fängt Bugs wo Service zu viele oder zu wenige Requests macht.

24.4 Komponenten mit Dependencies testen

Komponenten haben oft Service-Dependencies. Diese können echt oder gemockt sein. Echte Services testen Integration, gemockte Services isolieren die Komponente.

Ein Beispiel mit gemocktem Service:

import { TestBed, ComponentFixture } from '@angular/core/testing';
import { UserListComponent } from './user-list.component';
import { UserService } from './user.service';
import { of } from 'rxjs';

describe('UserListComponent', () => {
  let component: UserListComponent;
  let fixture: ComponentFixture<UserListComponent>;
  let userServiceSpy: jasmine.SpyObj<UserService>;
  
  beforeEach(async () => {
    const spy = jasmine.createSpyObj('UserService', ['getUsers']);
    
    await TestBed.configureTestingModule({
      declarations: [UserListComponent],
      providers: [
        { provide: UserService, useValue: spy }
      ]
    }).compileComponents();
    
    fixture = TestBed.createComponent(UserListComponent);
    component = fixture.componentInstance;
    userServiceSpy = TestBed.inject(UserService) as jasmine.SpyObj<UserService>;
  });
  
  it('should load users on init', () => {
    const mockUsers = [
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' }
    ];
    
    userServiceSpy.getUsers.and.returnValue(of(mockUsers));
    
    fixture.detectChanges(); // triggers ngOnInit
    
    expect(component.users).toEqual(mockUsers);
    expect(userServiceSpy.getUsers).toHaveBeenCalled();
  });
  
  it('should display users in template', () => {
    const mockUsers = [
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' }
    ];
    
    userServiceSpy.getUsers.and.returnValue(of(mockUsers));
    fixture.detectChanges();
    
    const compiled = fixture.nativeElement;
    const userElements = compiled.querySelectorAll('.user-item');
    
    expect(userElements.length).toBe(2);
    expect(userElements[0].textContent).toContain('Alice');
    expect(userElements[1].textContent).toContain('Bob');
  });
});

jasmine.createSpyObj erstellt ein Mock-Objekt mit Spy-Methoden. Das and.returnValue kontrolliert was die Methode zurückgibt. Das of erstellt ein Observable das sofort emittiert.

Die Provider-Konfiguration { provide: UserService, useValue: spy } injiziert den Spy statt des echten Service. Angular’s DI resolved UserService zum Spy-Objekt.

24.5 Asynchrones Testen

Angular-Anwendungen sind asynchron. HTTP-Requests, Timers, Observables – alles passiert outside des synchronen Flows. Tests müssen diese Asynchronität handhaben.

24.5.1 fakeAsync und tick

Die fakeAsync Zone simuliert Zeit synchron:

import { fakeAsync, tick } from '@angular/core/testing';

it('should debounce search input', fakeAsync(() => {
  component.search('test');
  
  tick(299); // Fast 300ms
  expect(component.searchCalled).toBe(false);
  
  tick(1); // Total 300ms
  expect(component.searchCalled).toBe(true);
}));

Innerhalb fakeAsync können asynchrone Operations durch tick() vorwärtsgespult werden. tick(300) simuliert 300ms Zeitverlauf ohne real zu warten. Dies macht Tests mit Timeouts oder Debouncing schnell.

flush() springt zum Ende aller ausstehenden asynchronen Tasks:

it('should handle multiple timers', fakeAsync(() => {
  setTimeout(() => component.value = 'first', 100);
  setTimeout(() => component.value = 'second', 200);
  
  flush(); // Completes alle Timers
  
  expect(component.value).toBe('second');
}));

24.5.2 async und whenStable

Die async Utility wartet auf asynchrone Operations:

import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';

it('should load data', waitForAsync(() => {
  component.loadData();
  
  fixture.whenStable().then(() => {
    fixture.detectChanges();
    expect(component.data).toBeDefined();
  });
}));

waitForAsync (früher async) wrapped den Test. fixture.whenStable() returnt ein Promise das resolves wenn alle asynchronen Tasks complete sind. Dies ist nötig für Promises und echte HTTP-Calls.

Die moderne Alternative nutzt async/await:

it('should load data', async () => {
  component.loadData();
  
  await fixture.whenStable();
  fixture.detectChanges();
  
  expect(component.data).toBeDefined();
});

Dies ist lesbarer als Promise-Callbacks.

24.5.3 done Callback

Für Observable-Tests kann das done-Callback verwendet werden:

it('should emit value', (done) => {
  component.valueChange.subscribe(value => {
    expect(value).toBe('test');
    done();
  });
  
  component.triggerChange('test');
});

Jasmine wartet bis done() called wird. Timeout nach 5 Sekunden (konfigurierbar). Dies funktioniert für Observables die einmal emittieren und completen.

24.6 Input und Output testen

Komponenten-Properties und Events sind Teil der Public API. Tests sollten diese verifizieren.

24.6.1 @Input testen

it('should accept input value', () => {
  component.title = 'Test Title';
  fixture.detectChanges();
  
  const compiled = fixture.nativeElement;
  expect(compiled.querySelector('h1').textContent).toBe('Test Title');
});

Direktes Property-Setzen funktioniert. Für komplexere Scenarios kann By.directive nützlich sein:

@Component({
  template: `<app-child [data]="parentData"></app-child>`
})
class TestHostComponent {
  parentData = 'test';
}

it('should pass input to child', () => {
  const fixture = TestBed.createComponent(TestHostComponent);
  fixture.detectChanges();
  
  const childComponent = fixture.debugElement.query(By.directive(ChildComponent)).componentInstance;
  expect(childComponent.data).toBe('test');
});

24.6.2 @Output testen

it('should emit event', () => {
  let emittedValue: string;
  
  component.valueChange.subscribe((value: string) => {
    emittedValue = value;
  });
  
  component.triggerEvent('test value');
  
  expect(emittedValue).toBe('test value');
});

EventEmitter sind Observables. Subscription fängt Emissions. Jasmine’s spy kann auch verwendet werden:

it('should emit event on click', () => {
  spyOn(component.clicked, 'emit');
  
  const button = fixture.nativeElement.querySelector('button');
  button.click();
  
  expect(component.clicked.emit).toHaveBeenCalledWith('clicked');
});

24.7 Forms testen

Formulare sind zentral in vielen Anwendungen. Angular bietet Template-driven und Reactive Forms. Beide benötigen spezifische Test-Strategien.

24.7.1 Reactive Forms testen

import { ReactiveFormsModule } from '@angular/forms';

describe('LoginComponent', () => {
  let component: LoginComponent;
  let fixture: ComponentFixture<LoginComponent>;
  
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [ReactiveFormsModule],
      declarations: [LoginComponent]
    }).compileComponents();
    
    fixture = TestBed.createComponent(LoginComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });
  
  it('should create form with required validators', () => {
    expect(component.loginForm.get('username')!.hasError('required')).toBe(true);
    expect(component.loginForm.get('password')!.hasError('required')).toBe(true);
    expect(component.loginForm.valid).toBe(false);
  });
  
  it('should validate email format', () => {
    const emailControl = component.loginForm.get('email')!;
    
    emailControl.setValue('invalid');
    expect(emailControl.hasError('email')).toBe(true);
    
    emailControl.setValue('valid@example.com');
    expect(emailControl.hasError('email')).toBe(false);
  });
  
  it('should disable submit button when form invalid', () => {
    const button = fixture.nativeElement.querySelector('button[type="submit"]');
    expect(button.disabled).toBe(true);
    
    component.loginForm.patchValue({
      username: 'user',
      password: 'pass'
    });
    fixture.detectChanges();
    
    expect(button.disabled).toBe(false);
  });
  
  it('should call service on submit', () => {
    spyOn(component, 'onSubmit');
    
    component.loginForm.patchValue({
      username: 'user',
      password: 'pass'
    });
    
    const form = fixture.nativeElement.querySelector('form');
    form.dispatchEvent(new Event('submit'));
    
    expect(component.onSubmit).toHaveBeenCalled();
  });
});

Reactive Forms sind programmatisch testbar ohne DOM-Interaktion. Validators können direkt asserted werden. Form-State ist explizit zugänglich.

24.7.2 Template-driven Forms testen

import { FormsModule } from '@angular/forms';

describe('ContactFormComponent', () => {
  let component: ContactFormComponent;
  let fixture: ComponentFixture<ContactFormComponent>;
  
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [FormsModule],
      declarations: [ContactFormComponent]
    }).compileComponents();
    
    fixture = TestBed.createComponent(ContactFormComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });
  
  it('should bind input to model', fakeAsync(() => {
    const input = fixture.nativeElement.querySelector('input[name="name"]');
    
    input.value = 'John Doe';
    input.dispatchEvent(new Event('input'));
    
    tick();
    fixture.detectChanges();
    
    expect(component.contact.name).toBe('John Doe');
  }));
});

Template-driven Forms benötigen DOM-Interaktion. Input-Events müssen dispatched werden. fakeAsync und tick handhaben das asynchrone Binding.

24.8 Direktiven testen

Direktiven verändern Elemente oder Verhalten. Tests verifizieren diese Modifikationen.

24.8.1 Attribut-Direktive testen

import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HighlightDirective } from './highlight.directive';

@Component({
  template: `<p appHighlight>Test</p>`
})
class TestComponent {}

describe('HighlightDirective', () => {
  let fixture: ComponentFixture<TestComponent>;
  
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [HighlightDirective, TestComponent]
    }).compileComponents();
    
    fixture = TestBed.createComponent(TestComponent);
    fixture.detectChanges();
  });
  
  it('should highlight element', () => {
    const element = fixture.nativeElement.querySelector('p');
    expect(element.style.backgroundColor).toBe('yellow');
  });
  
  it('should change color on hover', () => {
    const element = fixture.nativeElement.querySelector('p');
    
    element.dispatchEvent(new MouseEvent('mouseenter'));
    fixture.detectChanges();
    
    expect(element.style.backgroundColor).toBe('blue');
  });
});

Test-Components hosten die Direktive. Das Template nutzt die Direktive wie in Production. Das DOM wird asserted für Änderungen.

24.8.2 Struktur-Direktive testen

@Component({
  template: `
    <div *appUnless="condition">Content</div>
  `
})
class TestComponent {
  condition = false;
}

describe('UnlessDirective', () => {
  let fixture: ComponentFixture<TestComponent>;
  let component: TestComponent;
  
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [UnlessDirective, TestComponent]
    }).compileComponents();
    
    fixture = TestBed.createComponent(TestComponent);
    component = fixture.componentInstance;
  });
  
  it('should display when condition false', () => {
    component.condition = false;
    fixture.detectChanges();
    
    expect(fixture.nativeElement.textContent).toContain('Content');
  });
  
  it('should hide when condition true', () => {
    component.condition = true;
    fixture.detectChanges();
    
    expect(fixture.nativeElement.textContent).not.toContain('Content');
  });
});

Struktur-Direktiven manipulieren die View-Hierarchie. Tests verifizieren dass Elemente added/removed werden basierend auf Conditions.

24.9 Pipes testen

Pipes transformieren Daten. Sie sind pure Functions, was Testing vereinfacht.

import { TruncatePipe } from './truncate.pipe';

describe('TruncatePipe', () => {
  let pipe: TruncatePipe;
  
  beforeEach(() => {
    pipe = new TruncatePipe();
  });
  
  it('should truncate long text', () => {
    const text = 'This is a very long text that should be truncated';
    const result = pipe.transform(text, 20);
    
    expect(result).toBe('This is a very long ...');
    expect(result.length).toBe(23);
  });
  
  it('should not modify short text', () => {
    const text = 'Short';
    const result = pipe.transform(text, 20);
    
    expect(result).toBe('Short');
  });
  
  it('should handle empty input', () => {
    expect(pipe.transform('', 20)).toBe('');
    expect(pipe.transform(null as any, 20)).toBe('');
  });
});

Pipes werden direkt instantiiert ohne TestBed. Die transform-Methode wird mit verschiedenen Inputs getestet. Edge Cases wie empty input sollten gehandled werden.

24.10 Routing testen

Routing-Tests verifizieren Navigation und Route-Guards.

import { Location } from '@angular/common';
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';

describe('AppComponent Routing', () => {
  let router: Router;
  let location: Location;
  
  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [
        RouterTestingModule.withRoutes([
          { path: 'home', component: HomeComponent },
          { path: 'about', component: AboutComponent }
        ])
      ]
    });
    
    router = TestBed.inject(Router);
    location = TestBed.inject(Location);
  });
  
  it('should navigate to home', fakeAsync(() => {
    router.navigate(['/home']);
    tick();
    
    expect(location.path()).toBe('/home');
  }));
  
  it('should navigate to about', fakeAsync(() => {
    router.navigate(['/about']);
    tick();
    
    expect(location.path()).toBe('/about');
  }));
});

RouterTestingModule mockt Router-Infrastruktur. Location Service tracked die aktuelle URL. fakeAsync und tick handhaben asynchrone Navigation.

24.10.2 Route Guards testen

import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { AuthGuard } from './auth.guard';
import { AuthService } from './auth.service';

describe('AuthGuard', () => {
  let guard: AuthGuard;
  let authServiceSpy: jasmine.SpyObj<AuthService>;
  let routerSpy: jasmine.SpyObj<Router>;
  
  beforeEach(() => {
    const authSpy = jasmine.createSpyObj('AuthService', ['isLoggedIn']);
    const routeSpy = jasmine.createSpyObj('Router', ['navigate']);
    
    TestBed.configureTestingModule({
      providers: [
        AuthGuard,
        { provide: AuthService, useValue: authSpy },
        { provide: Router, useValue: routeSpy }
      ]
    });
    
    guard = TestBed.inject(AuthGuard);
    authServiceSpy = TestBed.inject(AuthService) as jasmine.SpyObj<AuthService>;
    routerSpy = TestBed.inject(Router) as jasmine.SpyObj<Router>;
  });
  
  it('should allow access when authenticated', () => {
    authServiceSpy.isLoggedIn.and.returnValue(true);
    
    expect(guard.canActivate({} as any, {} as any)).toBe(true);
  });
  
  it('should redirect when not authenticated', () => {
    authServiceSpy.isLoggedIn.and.returnValue(false);
    
    guard.canActivate({} as any, {} as any);
    
    expect(routerSpy.navigate).toHaveBeenCalledWith(['/login']);
  });
});

Guards sind Services mit spezifischer Interface. Tests verifizieren Authorization-Logik und Redirects.

Angular’s Testing-Infrastructure ist umfangreich. TestBed simuliert Angular’s Runtime-Environment. Jasmine bietet das Testing-Framework. Karma führt Tests in echten Browsern aus. Die Kombination ermöglicht konfidente Refactorings und kontinuierliche Qualitätssicherung.