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.
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.
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.
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.
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.
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.
Angular-Anwendungen sind asynchron. HTTP-Requests, Timers, Observables – alles passiert outside des synchronen Flows. Tests müssen diese Asynchronität handhaben.
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');
}));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.
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.
Komponenten-Properties und Events sind Teil der Public API. Tests sollten diese verifizieren.
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');
});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');
});Formulare sind zentral in vielen Anwendungen. Angular bietet Template-driven und Reactive Forms. Beide benötigen spezifische Test-Strategien.
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.
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.
Direktiven verändern Elemente oder Verhalten. Tests verifizieren diese Modifikationen.
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.
@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.
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.
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.
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.