25 Testen in Angular

25.1 Einführung in das Angular-Testing

Angular bietet ein umfangreiches Testing-Framework, das eng in die Entwicklungsumgebung integriert ist. Tests in Angular folgen dem Prinzip Prepare, Execute, Assert (Vorbereiten, Ausführen, Überprüfen):

  1. Prepare (Vorbereitung): Konfiguration der Testumgebung, Bereitstellung von Modulen und Abhängigkeiten
  2. Execute (Ausführung): Durchführung der zu testenden Aktion
  3. Assert (Überprüfung): Validierung der Ergebnisse gegen erwartete Resultate

Jede mit Angular CLI erstellte Komponente, Service oder andere Entität wird automatisch mit einer entsprechenden Spec-Datei generiert:

ng generate component my-component
# Erzeugt unter anderem: my-component.component.spec.ts

25.2 Das Testing-Framework

25.2.1 TestBed

Das Angular TestBed ist die zentrale API für Angular-Tests. Es ermöglicht die Konfiguration einer Testumgebung, die dem realen Angular-Modul ähnelt.

25.2.1.1 Hauptfunktionen des TestBed:

25.2.1.2 Beispiel der TestBed-Verwendung:

import { TestBed, ComponentFixture } from '@angular/core/testing';
import { MyComponent } from './my-component.component';
import { MyService } from './my-service.service';

describe('MyComponent', () => {
  let component: MyComponent;
  let fixture: ComponentFixture<MyComponent>;
  let service: MyService;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [MyComponent],
      providers: [MyService]
    }).compileComponents();
    
    fixture = TestBed.createComponent(MyComponent);
    component = fixture.componentInstance;
    service = TestBed.inject(MyService);
    fixture.detectChanges();
  });

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

25.2.2 Jest Alternative

Jest ist eine beliebte Alternative zum Standard-Testing-Setup in Angular. Es bietet schnellere Testausführung und erweiterte Features.

25.2.2.1 Vorteile von Jest:

25.2.2.2 Jest in einem Angular-Projekt einrichten:

  1. Installieren der erforderlichen Pakete:
npm install --save-dev jest @types/jest jest-preset-angular
  1. Erstellen einer jest.config.js Datei:
module.exports = {
  preset: 'jest-preset-angular',
  setupFilesAfterEnv: ['<rootDir>/setup-jest.ts'],
  globalSetup: 'jest-preset-angular/global-setup',
};
  1. Aktualisieren der Testbefehle in package.json:
"scripts": {
  "test": "jest",
  "test:watch": "jest --watch"
}

25.2.3 Cypress für E2E-Tests

Cypress ist ein modernes End-to-End Testing-Framework, das seit Angular 12+ als bevorzugte E2E-Testing-Option dient.

25.2.3.1 Schlüsselfunktionen von Cypress:

25.2.3.2 Cypress in einem Angular-Projekt einrichten:

ng add @cypress/schematic

25.2.3.3 Beispiel eines Cypress-Tests:

describe('Login Page', () => {
  beforeEach(() => {
    cy.visit('/login');
  });

  it('should display login form', () => {
    cy.get('form').should('be.visible');
    cy.get('input[name="username"]').should('be.visible');
    cy.get('input[name="password"]').should('be.visible');
    cy.get('button[type="submit"]').should('be.visible');
  });

  it('should log in successfully with valid credentials', () => {
    cy.get('input[name="username"]').type('testuser');
    cy.get('input[name="password"]').type('password123');
    cy.get('button[type="submit"]').click();
    
    cy.url().should('include', '/dashboard');
    cy.get('.welcome-message').should('contain', 'Welcome, testuser');
  });
});

25.2.4 Playwright für E2E-Tests

Playwright ist ein modernes End-to-End-Testing-Framework von Microsoft, das sich als leistungsstarke Alternative zu Cypress etabliert hat. Es bietet Cross-Browser-Unterstützung, parallele Testausführung und erweiterte Funktionen für moderne Webanwendungen.

25.2.4.1 Schlüsselfunktionen von Playwright:

25.2.4.2 Playwright in einem Angular-Projekt einrichten:

# Playwright installieren
npm init playwright@latest

# Oder zu bestehendem Projekt hinzufügen
npm install --save-dev @playwright/test
npx playwright install

25.2.4.3 Playwright-Konfiguration für Angular:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  
  use: {
    baseURL: 'http://localhost:4200',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure'
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] },
    },
    {
      name: 'Mobile Safari',
      use: { ...devices['iPhone 12'] },
    }
  ],

  webServer: {
    command: 'npm run start',
    url: 'http://localhost:4200',
    reuseExistingServer: !process.env.CI,
    timeout: 120 * 1000,
  },
});

25.2.4.4 Grundlegende Playwright-Tests für Angular:

// 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 elements', async ({ page }) => {
    // Überprüfung der Formularelemente
    await expect(page.locator('form')).toBeVisible();
    await expect(page.locator('input[name="username"]')).toBeVisible();
    await expect(page.locator('input[name="password"]')).toBeVisible();
    await expect(page.locator('button[type="submit"]')).toBeVisible();
  });

  test('should show validation errors for empty form', async ({ page }) => {
    // Versuche mit leerem Formular einzuloggen
    await page.click('button[type="submit"]');
    
    // Überprüfe Validierungsfehler
    await expect(page.locator('.error-message')).toContainText('Username is required');
    await expect(page.locator('.error-message')).toContainText('Password is required');
  });

  test('should login successfully with valid credentials', async ({ page }) => {
    // Eingabe von gültigen Anmeldedaten
    await page.fill('input[name="username"]', 'testuser');
    await page.fill('input[name="password"]', 'password123');
    
    // Login-Button klicken
    await page.click('button[type="submit"]');
    
    // Überprüfung der erfolgreichen Navigation
    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('.welcome-message')).toContainText('Welcome, testuser');
  });

  test('should show error for invalid credentials', async ({ page }) => {
    await page.fill('input[name="username"]', 'wronguser');
    await page.fill('input[name="password"]', 'wrongpassword');
    await page.click('button[type="submit"]');
    
    await expect(page.locator('.alert-error')).toContainText('Invalid credentials');
    await expect(page).toHaveURL('/login');
  });
});

25.2.4.5 Erweiterte Playwright-Features für Angular:

// e2e/shopping-cart.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Shopping Cart Functionality', () => {
  test.beforeEach(async ({ page }) => {
    // Login vor jedem Test
    await page.goto('/login');
    await page.fill('input[name="username"]', 'testuser');
    await page.fill('input[name="password"]', 'password123');
    await page.click('button[type="submit"]');
    await page.waitForURL('/dashboard');
  });

  test('should add product to cart', async ({ page }) => {
    await page.goto('/products');
    
    // Warten auf das Laden der Produkte
    await page.waitForSelector('.product-item');
    
    // Erstes Produkt auswählen
    const firstProduct = page.locator('.product-item').first();
    const productName = await firstProduct.locator('.product-name').textContent();
    const productPrice = await firstProduct.locator('.product-price').textContent();
    
    // Zum Warenkorb hinzufügen
    await firstProduct.locator('.add-to-cart-btn').click();
    
    // Bestätigung überprüfen
    await expect(page.locator('.cart-notification')).toContainText('Product added to cart');
    
    // Warenkorb öffnen
    await page.click('.cart-icon');
    
    // Überprüfung der Warenkorb-Inhalte
    const cartItem = page.locator('.cart-item').first();
    await expect(cartItem.locator('.item-name')).toContainText(productName || '');
    await expect(cartItem.locator('.item-price')).toContainText(productPrice || '');
  });

  test('should update product quantity in cart', async ({ page }) => {
    // Produkt zum Warenkorb hinzufügen (Setup)
    await page.goto('/products');
    await page.waitForSelector('.product-item');
    await page.locator('.product-item').first().locator('.add-to-cart-btn').click();
    
    // Warenkorb öffnen
    await page.click('.cart-icon');
    
    // Menge erhöhen
    const quantityInput = page.locator('.quantity-input').first();
    await quantityInput.fill('3');
    await quantityInput.press('Enter');
    
    // Warten auf Update
    await page.waitForTimeout(500);
    
    // Überprüfung der aktualisierten Gesamtsumme
    const totalPrice = page.locator('.total-price');
    await expect(totalPrice).not.toBeEmpty();
  });

  test('should complete checkout process', async ({ page }) => {
    // Produkt hinzufügen und Checkout starten
    await page.goto('/products');
    await page.waitForSelector('.product-item');
    await page.locator('.product-item').first().locator('.add-to-cart-btn').click();
    await page.click('.cart-icon');
    await page.click('.checkout-btn');
    
    // Checkout-Formular ausfüllen
    await page.fill('input[name="firstName"]', 'John');
    await page.fill('input[name="lastName"]', 'Doe');
    await page.fill('input[name="email"]', 'john.doe@example.com');
    await page.fill('input[name="address"]', '123 Main St');
    await page.fill('input[name="city"]', 'Anytown');
    await page.fill('input[name="zipCode"]', '12345');
    
    // Zahlungsinformationen
    await page.fill('input[name="cardNumber"]', '4111111111111111');
    await page.fill('input[name="expiryDate"]', '12/25');
    await page.fill('input[name="cvv"]', '123');
    
    // Bestellung abschließen
    await page.click('.place-order-btn');
    
    // Bestätigung überprüfen
    await expect(page).toHaveURL('/order-confirmation');
    await expect(page.locator('.success-message')).toContainText('Order placed successfully');
  });
});

25.2.4.6 Page Object Model mit Playwright:

// e2e/pages/login.page.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly usernameInput: Locator;
  readonly passwordInput: Locator;
  readonly loginButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.usernameInput = page.locator('input[name="username"]');
    this.passwordInput = page.locator('input[name="password"]');
    this.loginButton = page.locator('button[type="submit"]');
    this.errorMessage = page.locator('.alert-error');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(username: string, password: string) {
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }

  async expectErrorMessage(message: string) {
    await this.errorMessage.waitFor();
    const text = await this.errorMessage.textContent();
    return text?.includes(message) || false;
  }
}

// e2e/pages/dashboard.page.ts
export class DashboardPage {
  readonly page: Page;
  readonly welcomeMessage: Locator;
  readonly userMenu: Locator;
  readonly logoutButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.welcomeMessage = page.locator('.welcome-message');
    this.userMenu = page.locator('.user-menu');
    this.logoutButton = page.locator('.logout-btn');
  }

  async expectWelcomeMessage(username: string) {
    await this.welcomeMessage.waitFor();
    const text = await this.welcomeMessage.textContent();
    return text?.includes(`Welcome, ${username}`) || false;
  }

  async logout() {
    await this.userMenu.click();
    await this.logoutButton.click();
  }
}

// Verwendung des Page Object Models:
// e2e/login-with-pom.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/login.page';
import { DashboardPage } from './pages/dashboard.page';

test('should login using Page Object Model', async ({ page }) => {
  const loginPage = new LoginPage(page);
  const dashboardPage = new DashboardPage(page);

  await loginPage.goto();
  await loginPage.login('testuser', 'password123');
  
  await expect(page).toHaveURL('/dashboard');
  expect(await dashboardPage.expectWelcomeMessage('testuser')).toBeTruthy();
});

25.2.4.7 API-Testing mit Playwright:

// e2e/api/user-api.spec.ts
import { test, expect } from '@playwright/test';

test.describe('User API Tests', () => {
  let authToken: string;

  test.beforeAll(async ({ request }) => {
    // Authentifizierung für API-Tests
    const response = await request.post('/api/auth/login', {
      data: {
        username: 'testuser',
        password: 'password123'
      }
    });
    
    expect(response.ok()).toBeTruthy();
    const responseBody = await response.json();
    authToken = responseBody.token;
  });

  test('should get user profile', async ({ request }) => {
    const response = await request.get('/api/user/profile', {
      headers: {
        'Authorization': `Bearer ${authToken}`
      }
    });

    expect(response.ok()).toBeTruthy();
    const user = await response.json();
    expect(user).toHaveProperty('id');
    expect(user).toHaveProperty('username', 'testuser');
  });

  test('should update user profile', async ({ request }) => {
    const updatedUser = {
      firstName: 'Updated',
      lastName: 'User',
      email: 'updated@example.com'
    };

    const response = await request.put('/api/user/profile', {
      headers: {
        'Authorization': `Bearer ${authToken}`
      },
      data: updatedUser
    });

    expect(response.ok()).toBeTruthy();
    const responseBody = await response.json();
    expect(responseBody.firstName).toBe('Updated');
    expect(responseBody.lastName).toBe('User');
  });
});

25.2.4.8 Mobile Testing mit Playwright:

// e2e/mobile.spec.ts
import { test, expect, devices } from '@playwright/test';

test.describe('Mobile Responsive Tests', () => {
  test('should display mobile navigation', async ({ browser }) => {
    const context = await browser.newContext({
      ...devices['iPhone 12']
    });
    const page = await context.newPage();

    await page.goto('/');
    
    // Mobile Menu sollte sichtbar sein
    await expect(page.locator('.mobile-menu-toggle')).toBeVisible();
    
    // Desktop Menu sollte versteckt sein
    await expect(page.locator('.desktop-nav')).toBeHidden();
    
    // Mobile Menu öffnen
    await page.click('.mobile-menu-toggle');
    await expect(page.locator('.mobile-nav')).toBeVisible();

    await context.close();
  });

  test('should handle touch gestures', async ({ browser }) => {
    const context = await browser.newContext({
      ...devices['iPad Pro']
    });
    const page = await context.newPage();

    await page.goto('/products');
    
    // Touch-Scroll simulieren
    await page.touchscreen.tap(200, 300);
    await page.mouse.wheel(0, -500);
    
    // Überprüfung, dass Scroll funktioniert hat
    const scrollPosition = await page.evaluate(() => window.pageYOffset);
    expect(scrollPosition).toBeGreaterThan(0);

    await context.close();
  });
});

25.2.4.9 Visual Regression Testing:

// e2e/visual-regression.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Visual Regression Tests', () => {
  test('should match homepage screenshot', async ({ page }) => {
    await page.goto('/');
    await page.waitForLoadState('networkidle');
    
    // Screenshot der gesamten Seite
    await expect(page).toHaveScreenshot('homepage.png');
  });

  test('should match component screenshot', async ({ page }) => {
    await page.goto('/products');
    
    // Screenshot eines spezifischen Elements
    const productCard = page.locator('.product-card').first();
    await expect(productCard).toHaveScreenshot('product-card.png');
  });

  test('should match mobile layout', async ({ page }) => {
    await page.setViewportSize({ width: 375, height: 667 });
    await page.goto('/');
    
    await expect(page).toHaveScreenshot('homepage-mobile.png');
  });
});

25.2.4.10 Test-Fixtures und Setup:

// e2e/fixtures/auth.fixture.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/login.page';

type AuthFixtures = {
  authenticatedPage: any;
};

export const test = base.extend<AuthFixtures>({
  authenticatedPage: async ({ page }, use) => {
    // Automatische Anmeldung für jeden Test
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('testuser', 'password123');
    await page.waitForURL('/dashboard');
    
    await use(page);
  },
});

// Verwendung der Fixture:
// e2e/authenticated-tests.spec.ts
import { test, expect } from './fixtures/auth.fixture';

test('should access protected route', async ({ authenticatedPage }) => {
  await authenticatedPage.goto('/admin');
  await expect(authenticatedPage.locator('.admin-panel')).toBeVisible();
});

25.2.4.11 Performance Testing:

// e2e/performance.spec.ts
import { test, expect } from '@playwright/test';

test('should meet performance benchmarks', async ({ page }) => {
  // Performance-Metriken erfassen
  await page.goto('/', { waitUntil: 'networkidle' });
  
  const performanceMetrics = await page.evaluate(() => {
    return JSON.stringify(performance.timing);
  });
  
  const metrics = JSON.parse(performanceMetrics);
  const loadTime = metrics.loadEventEnd - metrics.navigationStart;
  
  // Überprüfung, dass die Seite in unter 3 Sekunden lädt
  expect(loadTime).toBeLessThan(3000);
});

test('should handle large datasets efficiently', async ({ page }) => {
  await page.goto('/users');
  
  // Messen der Zeit für das Laden einer großen Benutzerliste
  const startTime = Date.now();
  await page.waitForSelector('.user-item:nth-child(100)', { timeout: 10000 });
  const endTime = Date.now();
  
  const loadTime = endTime - startTime;
  expect(loadTime).toBeLessThan(5000);
});

25.2.4.12 Test-Ausführung und CI/CD-Integration:

// package.json Scripts
{
  "scripts": {
    "e2e": "playwright test",
    "e2e:headed": "playwright test --headed",
    "e2e:debug": "playwright test --debug",
    "e2e:report": "playwright show-report",
    "e2e:ui": "playwright test --ui"
  }
}
# .github/workflows/playwright.yml
name: Playwright Tests
on:
  push:
    branches: [ main, master ]
  pull_request:
    branches: [ main, master ]
jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-node@v3
      with:
        node-version: 18
    - name: Install dependencies
      run: npm ci
    - name: Install Playwright Browsers
      run: npx playwright install --with-deps
    - name: Run Playwright tests
      run: npm run e2e
    - uses: actions/upload-artifact@v3
      if: always()
      with:
        name: playwright-report
        path: playwright-report/
        retention-days: 30

25.2.4.13 Best Practices für Playwright in Angular:

  1. Verwenden Sie data-testid Attribute: Stabile Selektoren für Tests

    <button data-testid="submit-button">Submit</button>
  2. Implementieren Sie Page Object Model: Bessere Wartbarkeit und Wiederverwendbarkeit

  3. Nutzen Sie Auto-Warten: Playwright wartet automatisch auf Elemente - vermeiden Sie explizite Waits

  4. Parallele Testausführung: Konfigurieren Sie Worker für schnellere Testausführung

  5. Cross-Browser-Testing: Testen Sie in allen relevanten Browsern

  6. Visual Regression Testing: Nutzen Sie Screenshots für UI-Konsistenz

25.3 Arten von Tests

25.3.1 Unit-Tests

Unit-Tests prüfen einzelne Funktionen, Klassen oder Komponenten isoliert von anderen Teilen der Anwendung.

25.3.1.1 Merkmale:

25.3.1.2 Beispiel eines Unit-Tests für einen Service:

import { TestBed } from '@angular/core/testing';
import { CalculatorService } from './calculator.service';

describe('CalculatorService', () => {
  let service: CalculatorService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [CalculatorService]
    });
    service = TestBed.inject(CalculatorService);
  });

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

  it('should divide two numbers correctly', () => {
    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');
  });
});

25.3.2 Integrationstests

Integrationstests prüfen, wie verschiedene Komponenten, Services oder Module zusammenarbeiten.

25.3.2.1 Merkmale:

25.3.2.2 Beispiel eines Integrationstests für zusammenarbeitende Komponenten:

import { TestBed, ComponentFixture } from '@angular/core/testing';
import { ParentComponent } from './parent.component';
import { ChildComponent } from './child.component';
import { By } from '@angular/platform-browser';

describe('Parent-Child Integration', () => {
  let parentFixture: ComponentFixture<ParentComponent>;
  let parentComponent: ParentComponent;
  
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ParentComponent, ChildComponent]
    }).compileComponents();
    
    parentFixture = TestBed.createComponent(ParentComponent);
    parentComponent = parentFixture.componentInstance;
    parentFixture.detectChanges();
  });

  it('should pass data from parent to child', () => {
    parentComponent.dataForChild = 'Test Data';
    parentFixture.detectChanges();
    
    const childComponent = parentFixture.debugElement
      .query(By.directive(ChildComponent))
      .componentInstance;
    
    expect(childComponent.data).toBe('Test Data');
  });

  it('should handle event from child to parent', () => {
    const childDebugElement = parentFixture.debugElement.query(By.directive(ChildComponent));
    const childComponent = childDebugElement.componentInstance;
    
    spyOn(parentComponent, 'handleChildEvent');
    childComponent.buttonClicked.emit('Child Event');
    
    expect(parentComponent.handleChildEvent).toHaveBeenCalledWith('Child Event');
  });
});

25.3.3 End-to-End-Tests

End-to-End-Tests (E2E) simulieren die Benutzerinteraktion mit der Anwendung und testen den kompletten Anwendungsfluss.

25.3.3.1 Merkmale:

25.3.3.2 Beispiel eines E2E-Tests mit Cypress:

describe('Todo App', () => {
  beforeEach(() => {
    cy.visit('/');
  });

  it('should add a new todo item', () => {
    cy.get('.todo-input').type('Learn Cypress Testing');
    cy.get('.add-button').click();
    
    cy.get('.todo-list').should('contain', 'Learn Cypress Testing');
    cy.get('.todo-count').should('contain', '1 item left');
  });

  it('should mark a todo as completed', () => {
    // Add a todo first
    cy.get('.todo-input').type('Learn Cypress Testing');
    cy.get('.add-button').click();
    
    // Mark it as completed
    cy.get('.todo-item').first().find('.toggle').click();
    
    // Verify it's marked as completed
    cy.get('.todo-item').first().should('have.class', 'completed');
    cy.get('.todo-count').should('contain', '0 items left');
  });
});

25.4 Komponententests

25.4.1 Shallow vs. Deep Rendering

25.4.1.1 Shallow Rendering mit NO_ERRORS_SCHEMA:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ParentComponent } from './parent.component';

describe('ParentComponent (Shallow)', () => {
  let component: ParentComponent;
  let fixture: ComponentFixture<ParentComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ParentComponent],
      schemas: [NO_ERRORS_SCHEMA] // Ignoriert unbekannte Elemente und Attribute
    }).compileComponents();
    
    fixture = TestBed.createComponent(ParentComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

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

25.4.1.2 Deep Rendering mit allen Kind-Komponenten:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ParentComponent } from './parent.component';
import { ChildComponent } from './child.component';
import { GrandchildComponent } from './grandchild.component';

describe('ParentComponent (Deep)', () => {
  let component: ParentComponent;
  let fixture: ComponentFixture<ParentComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [
        ParentComponent, 
        ChildComponent, 
        GrandchildComponent
      ]
    }).compileComponents();
    
    fixture = TestBed.createComponent(ParentComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create with all child components', () => {
    const childElements = fixture.debugElement.queryAll(By.directive(ChildComponent));
    expect(childElements.length).toBe(2); // Angenommen, es gibt 2 Kind-Komponenten
  });
});

25.4.2 Testen von Bindings

Angular-Komponenten haben verschiedene Arten von Bindings, die getestet werden sollten:

25.4.2.1 Property Bindings testen:

it('should bind header text correctly', () => {
  component.headerText = 'Test Header';
  fixture.detectChanges();
  
  const headerElement = fixture.debugElement.query(By.css('h1')).nativeElement;
  expect(headerElement.textContent).toBe('Test Header');
});

25.4.2.2 Event Bindings testen:

it('should call onSave method when save button is clicked', () => {
  spyOn(component, 'onSave');
  
  const saveButton = fixture.debugElement.query(By.css('.save-button')).nativeElement;
  saveButton.click();
  
  expect(component.onSave).toHaveBeenCalled();
});

25.4.2.3 Two-way Bindings testen:

it('should update model when input value changes', () => {
  const inputElement = fixture.debugElement.query(By.css('input')).nativeElement;
  
  inputElement.value = 'New Value';
  inputElement.dispatchEvent(new Event('input'));
  
  expect(component.inputValue).toBe('New Value');
});

25.4.3 Testen von Ereignissen

Ereignisse in Angular-Komponenten umfassen DOM-Ereignisse und benutzerdefinierte Angular-Ereignisse:

25.4.3.1 DOM-Ereignisse testen:

it('should update counter on button click', () => {
  expect(component.counter).toBe(0);
  
  const button = fixture.debugElement.query(By.css('.increment-button')).nativeElement;
  button.click();
  
  expect(component.counter).toBe(1);
});

25.4.3.2 Benutzerdefinierte Ereignisse (@Output) testen:

it('should emit selected item when item is clicked', () => {
  let selectedItem: any;
  component.itemSelected.subscribe((item: any) => selectedItem = item);
  
  component.selectItem({ id: 1, name: 'Test Item' });
  
  expect(selectedItem).toEqual({ id: 1, name: 'Test Item' });
});

25.4.4 Testen von Lebenszyklusmethoden

Angular-Komponenten haben verschiedene Lifecycle Hooks, die wichtige Logik enthalten können:

25.4.4.1 ngOnInit testen:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DataComponent } from './data.component';
import { DataService } from './data.service';
import { of } from 'rxjs';

describe('DataComponent', () => {
  let component: DataComponent;
  let fixture: ComponentFixture<DataComponent>;
  let dataService: jasmine.SpyObj<DataService>;

  beforeEach(async () => {
    const dataServiceSpy = jasmine.createSpyObj('DataService', ['getData']);
    
    await TestBed.configureTestingModule({
      declarations: [DataComponent],
      providers: [
        { provide: DataService, useValue: dataServiceSpy }
      ]
    }).compileComponents();
    
    fixture = TestBed.createComponent(DataComponent);
    component = fixture.componentInstance;
    dataService = TestBed.inject(DataService) as jasmine.SpyObj<DataService>;
  });

  it('should load data on ngOnInit', () => {
    const testData = [{ id: 1, name: 'Item 1' }, { id: 2, name: 'Item 2' }];
    dataService.getData.and.returnValue(of(testData));
    
    // ngOnInit wird automatisch aufgerufen, wenn detectChanges aufgerufen wird
    fixture.detectChanges();
    
    expect(dataService.getData).toHaveBeenCalled();
    expect(component.items).toEqual(testData);
  });
});

25.4.4.2 ngOnChanges testen:

import { SimpleChange } from '@angular/core';

it('should update computed values when inputs change', () => {
  // Set initial values
  component.firstName = 'John';
  component.lastName = 'Doe';
  
  // Simulate ngOnChanges with SimpleChanges object
  component.ngOnChanges({
    firstName: new SimpleChange(null, 'John', true),
    lastName: new SimpleChange(null, 'Doe', true)
  });
  
  expect(component.fullName).toBe('John Doe');
  
  // Test subsequent change
  component.ngOnChanges({
    firstName: new SimpleChange('John', 'Jane', false)
  });
  
  expect(component.fullName).toBe('Jane Doe');
});

25.5 Service-Tests

25.5.1 Testen von Abhängigkeitsinjektionen

Angular Services können Abhängigkeiten haben, die beim Testen gemockt werden müssen:

import { TestBed } from '@angular/core/testing';
import { UserService } from './user.service';
import { AuthService } from './auth.service';
import { LoggerService } from './logger.service';

describe('UserService', () => {
  let userService: UserService;
  let authServiceSpy: jasmine.SpyObj<AuthService>;
  let loggerServiceSpy: jasmine.SpyObj<LoggerService>;

  beforeEach(() => {
    const authSpy = jasmine.createSpyObj('AuthService', ['isAuthenticated', 'getCurrentUser']);
    const loggerSpy = jasmine.createSpyObj('LoggerService', ['log', 'error']);
    
    TestBed.configureTestingModule({
      providers: [
        UserService,
        { provide: AuthService, useValue: authSpy },
        { provide: LoggerService, useValue: loggerSpy }
      ]
    });
    
    userService = TestBed.inject(UserService);
    authServiceSpy = TestBed.inject(AuthService) as jasmine.SpyObj<AuthService>;
    loggerServiceSpy = TestBed.inject(LoggerService) as jasmine.SpyObj<LoggerService>;
  });

  it('should return user profile when authenticated', () => {
    const mockUser = { id: 1, name: 'Test User' };
    authServiceSpy.isAuthenticated.and.returnValue(true);
    authServiceSpy.getCurrentUser.and.returnValue(mockUser);
    
    const result = userService.getUserProfile();
    
    expect(authServiceSpy.isAuthenticated).toHaveBeenCalled();
    expect(authServiceSpy.getCurrentUser).toHaveBeenCalled();
    expect(result).toEqual(mockUser);
  });

  it('should return null when not authenticated', () => {
    authServiceSpy.isAuthenticated.and.returnValue(false);
    
    const result = userService.getUserProfile();
    
    expect(authServiceSpy.isAuthenticated).toHaveBeenCalled();
    expect(authServiceSpy.getCurrentUser).not.toHaveBeenCalled();
    expect(result).toBeNull();
  });
});

25.5.2 Testen von HTTP-Anfragen

Für das Testen von Services, die HTTP-Anfragen stellen, bietet Angular das HttpClientTestingModule:

import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { ProductService } from './product.service';
import { Product } from './product.model';

describe('ProductService', () => {
  let service: ProductService;
  let httpMock: HttpTestingController;

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

  afterEach(() => {
    httpMock.verify(); // Stellt sicher, dass keine unbeantworteten Anfragen übrig sind
  });

  it('should get all products via GET', () => {
    const mockProducts: Product[] = [
      { id: 1, name: 'Product 1', price: 100 },
      { id: 2, name: 'Product 2', price: 200 }
    ];
    
    service.getProducts().subscribe(products => {
      expect(products).toEqual(mockProducts);
    });
    
    const req = httpMock.expectOne('api/products');
    expect(req.request.method).toBe('GET');
    req.flush(mockProducts);
  });

  it('should create a product via POST', () => {
    const newProduct: Product = { id: 3, name: 'Product 3', price: 300 };
    
    service.createProduct(newProduct).subscribe(product => {
      expect(product).toEqual(newProduct);
    });
    
    const req = httpMock.expectOne('api/products');
    expect(req.request.method).toBe('POST');
    expect(req.request.body).toEqual(newProduct);
    req.flush(newProduct);
  });

  it('should handle error correctly', () => {
    service.getProducts().subscribe({
      next: () => fail('should have failed with a 404'),
      error: (error) => {
        expect(error.status).toBe(404);
        expect(error.statusText).toBe('Not Found');
      }
    });
    
    const req = httpMock.expectOne('api/products');
    req.flush('Not found', { status: 404, statusText: 'Not Found' });
  });
});

25.6 Direktiven und Pipes testen

25.6.1 Direktiven testen

Strukturelle und Attribut-Direktiven können mit Hilfe einer Test-Komponente getestet werden:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
import { HighlightDirective } from './highlight.directive';

// Testkomponente, die die zu testende Direktive verwendet
@Component({
  template: `
    <div [appHighlight]="color" defaultColor="yellow">Highlight me!</div>
  `
})
class TestComponent {
  color = 'red';
}

describe('HighlightDirective', () => {
  let component: TestComponent;
  let fixture: ComponentFixture<TestComponent>;
  let highlightedElement: DebugElement;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [HighlightDirective, TestComponent]
    }).compileComponents();
    
    fixture = TestBed.createComponent(TestComponent);
    component = fixture.componentInstance;
    highlightedElement = fixture.debugElement.query(By.css('div'));
    fixture.detectChanges();
  });

  it('should highlight with default color when no input is provided', () => {
    component.color = '';
    fixture.detectChanges();
    expect(highlightedElement.nativeElement.style.backgroundColor).toBe('yellow');
  });

  it('should highlight with provided color', () => {
    expect(highlightedElement.nativeElement.style.backgroundColor).toBe('red');
  });

  it('should change highlight color when input changes', () => {
    component.color = 'blue';
    fixture.detectChanges();
    expect(highlightedElement.nativeElement.style.backgroundColor).toBe('blue');
  });
});

25.6.2 Pipes testen

Pipes können direkt als Klassen getestet werden:

import { DateFormatPipe } from './date-format.pipe';

describe('DateFormatPipe', () => {
  let pipe: DateFormatPipe;

  beforeEach(() => {
    pipe = new DateFormatPipe();
  });

  it('should format date correctly with default format', () => {
    const date = new Date(2023, 0, 15); // 15. Januar 2023
    const result = pipe.transform(date);
    expect(result).toBe('15.01.2023');
  });

  it('should format date with custom format', () => {
    const date = new Date(2023, 0, 15);
    const result = pipe.transform(date, 'yyyy/MM/dd');
    expect(result).toBe('2023/01/15');
  });

  it('should return empty string for null input', () => {
    const result = pipe.transform(null);
    expect(result).toBe('');
  });
});

25.7 Mocking und Spying

Mocking und Spying sind wichtige Techniken, um isolierte Tests zu schreiben.

25.7.1 Jasmine Spies

Spies können verwendet werden, um Methodenaufrufe zu überwachen und Rückgabewerte zu kontrollieren:

it('should call service method when button is clicked', () => {
  // Spy auf eine Methode setzen
  spyOn(dataService, 'getData').and.returnValue(of(['item1', 'item2']));
  
  // Aktion auslösen
  const button = fixture.debugElement.query(By.css('.load-button'));
  button.nativeElement.click();
  
  // Überprüfen
  expect(dataService.getData).toHaveBeenCalled();
  expect(component.items.length).toBe(2);
});

25.7.2 Spy-Objekte erstellen

// Spy-Objekt mit mehreren Methoden erstellen
const serviceStub = jasmine.createSpyObj('DataService', ['getData', 'updateData', 'deleteData']);
serviceStub.getData.and.returnValue(of(['item1', 'item2']));
serviceStub.updateData.and.returnValue(of(true));

// In TestBed bereitstellen
TestBed.configureTestingModule({
  providers: [
    { provide: DataService, useValue: serviceStub }
  ]
});

25.7.3 Mock-Services

Manchmal ist es einfacher, einen vollständigen Mock-Service zu erstellen:

// Mock-Service-Klasse
class MockAuthService {
  isAuthenticated = true;
  
  login(username: string, password: string) {
    return of({ success: true, user: { id: 1, name: username } });
  }
  
  logout() {
    this.isAuthenticated = false;
    return of(true);
  }
}

// In TestBed bereitstellen
TestBed.configureTestingModule({
  providers: [
    { provide: AuthService, useClass: MockAuthService }
  ]
});

25.8 Testing mit RxJS

Das Testen von reaktiver Programmierung mit RxJS erfordert spezielle Techniken.

25.8.1 Manuelles Testen von Observables

import { fakeAsync, tick } from '@angular/core/testing';
import { of, throwError, timer } from 'rxjs';
import { delay, map, catchError } from 'rxjs/operators';

it('should handle delayed observable', fakeAsync(() => {
  let result: any;
  
  // Observable mit Verzögerung
  of('delayed value').pipe(
    delay(1000)
  ).subscribe(value => {
    result = value;
  });
  
  // Anfangs kein Ergebnis
  expect(result).toBeUndefined();
  
  // Zeit voranschreiten lassen
  tick(1000);
  
  // Nach Verzögerung sollte Wert gesetzt sein
  expect(result).toBe('delayed value');
}));

25.8.2 TestScheduler

Für komplexere RxJS-Tests kann der TestScheduler verwendet werden:

import { TestScheduler } from 'rxjs/testing';

describe('RxJS Testing', () => {
  let testScheduler: TestScheduler;

  beforeEach(() => {
    testScheduler = new TestScheduler((actual, expected) => {
      expect(actual).toEqual(expected);
    });
  });

  it('should debounce input values', () => {
    testScheduler.run(({ hot, expectObservable }) => {
      const source = hot('-a-b-c-d-|', { a: 1, b: 2, c: 3, d: 4 });
      const result = source.pipe(debounceTime(2));
      const expected = '-----b-c-d|';
      
      expectObservable(result).toBe(expected, { b: 2, c: 3, d: 4 });
    });
  });
});

25.9 Tests für Formulare in Angular

25.9.1 Template-Driven Forms

Template-Driven Forms sind eine einfache Möglichkeit, Formulare in Angular zu erstellen. Ihre Tests konzentrieren sich auf DOM-Interaktionen und NgModel-Bindings.

import { Component } from '@angular/core';
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';

@Component({
  template: `
    <form #myForm="ngForm" (ngSubmit)="onSubmit(myForm.value)">
      <input name="username" [(ngModel)]="user.username" required>
      <input name="email" [(ngModel)]="user.email" required email>
      <button type="submit" [disabled]="!myForm.valid">Submit</button>
    </form>
  `
})
class TestFormComponent {
  user = { username: '', email: '' };
  submitted = false;
  
  onSubmit(formValue: any) {
    this.submitted = true;
    this.user = formValue;
  }
}

describe('Template-Driven Form', () => {
  let component: TestFormComponent;
  let fixture: ComponentFixture<TestFormComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [FormsModule],
      declarations: [TestFormComponent]
    }).compileComponents();
    
    fixture = TestBed.createComponent(TestFormComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create the form with empty values', () => {
    const usernameInput = fixture.debugElement.query(By.css('input[name="username"]')).nativeElement;
    const emailInput = fixture.debugElement.query(By.css('input[name="email"]')).nativeElement;
    
    expect(usernameInput.value).toBe('');
    expect(emailInput.value).toBe('');
  });

  it('should disable submit button when form is invalid', () => {
    const submitButton = fixture.debugElement.query(By.css('button[type="submit"]')).nativeElement;
    expect(submitButton.disabled).toBeTruthy();
  });

  it('should enable submit button when form is valid', fakeAsync(() => {
    const usernameInput = fixture.debugElement.query(By.css('input[name="username"]')).nativeElement;
    const emailInput = fixture.debugElement.query(By.css('input[name="email"]')).nativeElement;
    const submitButton = fixture.debugElement.query(By.css('button[type="submit"]')).nativeElement;
    
    usernameInput.value = 'testuser';
    usernameInput.dispatchEvent(new Event('input'));
    
    emailInput.value = 'test@example.com';
    emailInput.dispatchEvent(new Event('input'));
    
    fixture.detectChanges();
    tick(); // Warte auf asynchrone Validierung
    fixture.detectChanges();
    
    expect(submitButton.disabled).toBeFalsy();
  }));

  it('should call onSubmit method when form is submitted', fakeAsync(() => {
    spyOn(component, 'onSubmit').and.callThrough();
    
    // Formular ausfüllen
    const usernameInput = fixture.debugElement.query(By.css('input[name="username"]')).nativeElement;
    const emailInput = fixture.debugElement.query(By.css('input[name="email"]')).nativeElement;
    const form = fixture.debugElement.query(By.css('form')).nativeElement;
    
    usernameInput.value = 'testuser';
    usernameInput.dispatchEvent(new Event('input'));
    
    emailInput.value = 'test@example.com';
    emailInput.dispatchEvent(new Event('input'));
    
    fixture.detectChanges();
    tick();
    fixture.detectChanges();
    
    // Formular absenden
    form.dispatchEvent(new Event('submit'));
    
    expect(component.onSubmit).toHaveBeenCalled();
    expect(component.submitted).toBeTruthy();
    expect(component.user.username).toBe('testuser');
    expect(component.user.email).toBe('test@example.com');
  }));
});

25.9.2 Reactive Forms

Reactive Forms bieten mehr Kontrolle und sind daher oft einfacher zu testen als Template-Driven Forms.

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';

@Component({
  template: `
    <form [formGroup]="registerForm" (ngSubmit)="onSubmit()">
      <input formControlName="username">
      <div *ngIf="username.invalid && username.touched" class="error">
        Username is required
      </div>
      
      <input formControlName="email">
      <div *ngIf="email.invalid && email.touched" class="error">
        Valid email is required
      </div>
      
      <input type="password" formControlName="password">
      <div *ngIf="password.invalid && password.touched" class="error">
        Password must be at least 6 characters
      </div>
      
      <button type="submit" [disabled]="registerForm.invalid">Register</button>
    </form>
  `
})
class ReactiveFormComponent implements OnInit {
  registerForm!: FormGroup;
  submitted = false;
  
  constructor(private fb: FormBuilder) {}
  
  ngOnInit() {
    this.registerForm = this.fb.group({
      username: ['', Validators.required],
      email: ['', [Validators.required, Validators.email]],
      password: ['', [Validators.required, Validators.minLength(6)]]
    });
  }
  
  get username() { return this.registerForm.get('username')!; }
  get email() { return this.registerForm.get('email')!; }
  get password() { return this.registerForm.get('password')!; }
  
  onSubmit() {
    this.submitted = true;
    if (this.registerForm.valid) {
      // Logik für erfolgreiche Registrierung
    }
  }
}

describe('Reactive Form Component', () => {
  let component: ReactiveFormComponent;
  let fixture: ComponentFixture<ReactiveFormComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [ReactiveFormsModule],
      declarations: [ReactiveFormComponent]
    }).compileComponents();
    
    fixture = TestBed.createComponent(ReactiveFormComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create form with 3 controls', () => {
    expect(component.registerForm.contains('username')).toBeTruthy();
    expect(component.registerForm.contains('email')).toBeTruthy();
    expect(component.registerForm.contains('password')).toBeTruthy();
  });

  it('should mark username as invalid when empty', () => {
    const control = component.registerForm.get('username');
    control?.setValue('');
    expect(control?.valid).toBeFalsy();
  });

  it('should mark username as valid when it has a value', () => {
    const control = component.registerForm.get('username');
    control?.setValue('testuser');
    expect(control?.valid).toBeTruthy();
  });

  it('should mark email as invalid when not in correct format', () => {
    const control = component.registerForm.get('email');
    control?.setValue('invalid-email');
    expect(control?.valid).toBeFalsy();
  });

  it('should mark password as invalid when less than 6 characters', () => {
    const control = component.registerForm.get('password');
    control?.setValue('12345');
    expect(control?.valid).toBeFalsy();
  });

  it('should disable submit button when form is invalid', () => {
    // Form ist initial leer und somit invalid
    const submitButton = fixture.debugElement.query(By.css('button[type="submit"]')).nativeElement;
    expect(submitButton.disabled).toBeTruthy();
  });

  it('should enable submit button when form is valid', () => {
    // Formular mit gültigen Werten ausfüllen
    component.registerForm.setValue({
      username: 'testuser',
      email: 'test@example.com',
      password: 'password123'
    });
    fixture.detectChanges();
    
    const submitButton = fixture.debugElement.query(By.css('button[type="submit"]')).nativeElement;
    expect(submitButton.disabled).toBeFalsy();
  });

  it('should call onSubmit method when form is submitted', () => {
    spyOn(component, 'onSubmit');
    
    // Formular mit gültigen Werten ausfüllen
    component.registerForm.setValue({
      username: 'testuser',
      email: 'test@example.com',
      password: 'password123'
    });
    fixture.detectChanges();
    
    // Formular absenden
    const form = fixture.debugElement.query(By.css('form'));
    form.triggerEventHandler('submit', null);
    
    expect(component.onSubmit).toHaveBeenCalled();
  });

  it('should show error message when username field is touched and empty', () => {
    // Username-Feld berühren und leer lassen
    const usernameControl = component.registerForm.get('username');
    usernameControl?.setValue('');
    usernameControl?.markAsTouched();
    fixture.detectChanges();
    
    const errorMessage = fixture.debugElement.query(By.css('.error'));
    expect(errorMessage).toBeTruthy();
    expect(errorMessage.nativeElement.textContent).toContain('Username is required');
  });
});

25.9.3 Formular-Validierung testen

Beim Testen der Formularvalidierung ist es wichtig, sowohl die Validierungslogik als auch die Anzeige von Fehlermeldungen zu überprüfen.

it('should validate email format correctly', () => {
  const emailControl = component.registerForm.get('email');
  
  // Ungültige Email
  emailControl?.setValue('not-an-email');
  expect(emailControl?.hasError('email')).toBeTruthy();
  
  // Gültige Email
  emailControl?.setValue('valid@example.com');
  expect(emailControl?.hasError('email')).toBeFalsy();
});

it('should validate password complexity', () => {
  const passwordControl = component.registerForm.get('password');
  const complexPasswordValidator = (control: AbstractControl) => {
    const value = control.value;
    const hasNumber = /\d/.test(value);
    const hasUpper = /[A-Z]/.test(value);
    const hasLower = /[a-z]/.test(value);
    
    return hasNumber && hasUpper && hasLower ? null : { complexity: true };
  };
  
  // Validator zur Laufzeit hinzufügen
  passwordControl?.setValidators([
    Validators.required,
    Validators.minLength(6),
    complexPasswordValidator
  ]);
  passwordControl?.updateValueAndValidity();
  
  // Einfaches Passwort
  passwordControl?.setValue('simple');
  expect(passwordControl?.hasError('complexity')).toBeTruthy();
  
  // Komplexes Passwort
  passwordControl?.setValue('Complex123');
  expect(passwordControl?.hasError('complexity')).toBeFalsy();
});

25.9.4 Asynchrone Validatoren testen

Asynchrone Validatoren erfordern besondere Aufmerksamkeit beim Testen:

import { fakeAsync, tick } from '@angular/core/testing';
import { of } from 'rxjs';
import { delay } from 'rxjs/operators';

// Mock-Service für asynchrone Validierung
class UserService {
  isUsernameTaken(username: string) {
    const isTaken = username === 'existinguser';
    return of(isTaken).pipe(delay(100));
  }
}

// Asynchroner Validator
const usernameAsyncValidator = (userService: UserService) => {
  return (control: AbstractControl) => {
    return userService.isUsernameTaken(control.value).pipe(
      map(isTaken => isTaken ? { usernameTaken: true } : null)
    );
  };
};

describe('Async Validator Tests', () => {
  let component: ReactiveFormComponent;
  let fixture: ComponentFixture<ReactiveFormComponent>;
  let userService: UserService;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [ReactiveFormsModule],
      declarations: [ReactiveFormComponent],
      providers: [UserService]
    }).compileComponents();
    
    fixture = TestBed.createComponent(ReactiveFormComponent);
    component = fixture.componentInstance;
    userService = TestBed.inject(UserService);
    
    // FormGroup mit asynchronem Validator erstellen
    component.registerForm = new FormBuilder().group({
      username: ['', Validators.required, usernameAsyncValidator(userService)]
    });
    
    fixture.detectChanges();
  });

  it('should validate username availability asynchronously', fakeAsync(() => {
    const usernameControl = component.registerForm.get('username');
    
    // Prüfen eines bereits vorhandenen Benutzernamens
    usernameControl?.setValue('existinguser');
    tick(100); // Warte auf die asynchrone Validierung
    
    expect(usernameControl?.hasError('usernameTaken')).toBeTruthy();
    expect(usernameControl?.valid).toBeFalsy();
    
    // Prüfen eines verfügbaren Benutzernamens
    usernameControl?.setValue('newuser');
    tick(100);
    
    expect(usernameControl?.hasError('usernameTaken')).toBeFalsy();
    expect(usernameControl?.valid).toBeTruthy();
  }));
});

25.10 Routing-Tests in Angular

Das Testen von Routing in Angular ist ein wichtiger Aspekt, um die Navigation und den Zustand der Anwendung zu überprüfen.

25.10.1 Grundlegende Routing-Tests

Zum Testen des Routings benötigen wir das RouterTestingModule:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { Router } from '@angular/router';
import { Location } from '@angular/common';
import { Component } from '@angular/core';
import { routes } from './app-routing.module';

// Stub-Komponenten für Routes
@Component({ template: '<div>Home Component</div>' })
class HomeComponent {}

@Component({ template: '<div>About Component</div>' })
class AboutComponent {}

@Component({ template: '<div>Contact Component</div>' })
class ContactComponent {}

@Component({ template: '<router-outlet></router-outlet>' })
class AppComponent {}

describe('Router Navigation', () => {
  let router: Router;
  let location: Location;
  let fixture: ComponentFixture<AppComponent>;

  beforeEach(async () => {
    const testRoutes = [
      { path: '', component: HomeComponent },
      { path: 'about', component: AboutComponent },
      { path: 'contact', component: ContactComponent }
    ];

    await TestBed.configureTestingModule({
      imports: [RouterTestingModule.withRoutes(testRoutes)],
      declarations: [
        AppComponent,
        HomeComponent,
        AboutComponent,
        ContactComponent
      ]
    }).compileComponents();

    router = TestBed.inject(Router);
    location = TestBed.inject(Location);
    fixture = TestBed.createComponent(AppComponent);
    fixture.detectChanges();
  });

  it('should navigate to "" redirects to /home', async () => {
    await router.navigateByUrl('');
    expect(location.path()).toBe('/');
  });

  it('should navigate to "about"', async () => {
    await router.navigateByUrl('/about');
    expect(location.path()).toBe('/about');
  });

  it('should navigate to "contact"', async () => {
    await router.navigateByUrl('/contact');
    expect(location.path()).toBe('/contact');
  });
});

Um zu testen, ob Links richtig funktionieren:

import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { Router } from '@angular/router';
import { By } from '@angular/platform-browser';
import { RouterLinkWithHref } from '@angular/router';

@Component({
  template: `
    <nav>
      <a routerLink="/">Home</a>
      <a routerLink="/about">About</a>
      <a routerLink="/contact">Contact</a>
    </nav>
  `
})
class NavComponent {}

describe('NavComponent RouterLinks', () => {
  let component: NavComponent;
  let fixture: ComponentFixture<NavComponent>;
  let router: Router;
  let debugElements: any[];

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

    fixture = TestBed.createComponent(NavComponent);
    component = fixture.componentInstance;
    router = TestBed.inject(Router);
    
    fixture.detectChanges();
    
    // Sammle alle RouterLink-Elemente
    debugElements = fixture.debugElement.queryAll(By.directive(RouterLinkWithHref));
  });

  it('should have 3 RouterLinks', () => {
    expect(debugElements.length).toBe(3);
  });

  it('should have a RouterLink to "/"', () => {
    const homeLink = debugElements.find(de => de.attributes['routerLink'] === '/');
    expect(homeLink).toBeTruthy();
    expect(homeLink.nativeElement.textContent).toBe('Home');
  });

  it('should have a RouterLink to "/about"', () => {
    const aboutLink = debugElements.find(de => de.attributes['routerLink'] === '/about');
    expect(aboutLink).toBeTruthy();
    expect(aboutLink.nativeElement.textContent).toBe('About');
  });

  it('should navigate when link is clicked', () => {
    spyOn(router, 'navigateByUrl');
    
    const aboutLink = debugElements.find(de => de.attributes['routerLink'] === '/about');
    aboutLink.triggerEventHandler('click', { button: 0 });
    
    const routerCall = (router.navigateByUrl as jasmine.Spy).calls.first().args[0];
    expect(routerCall.toString()).toBe('/about');
  });
});

25.10.3 Testen von Route-Guards

Route-Guards können wie folgt getestet werden:

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

describe('AuthGuard', () => {
  let guard: AuthGuard;
  let authService: jasmine.SpyObj<AuthService>;
  let router: jasmine.SpyObj<Router>;
  let routeSnapshot: ActivatedRouteSnapshot;
  let routerStateSnapshot: RouterStateSnapshot;

  beforeEach(() => {
    const authServiceSpy = jasmine.createSpyObj('AuthService', ['isAuthenticated']);
    const routerSpy = jasmine.createSpyObj('Router', ['navigate']);

    TestBed.configureTestingModule({
      imports: [RouterTestingModule],
      providers: [
        AuthGuard,
        { provide: AuthService, useValue: authServiceSpy },
        { provide: Router, useValue: routerSpy }
      ]
    });

    guard = TestBed.inject(AuthGuard);
    authService = TestBed.inject(AuthService) as jasmine.SpyObj<AuthService>;
    router = TestBed.inject(Router) as jasmine.SpyObj<Router>;
    
    routeSnapshot = new ActivatedRouteSnapshot();
    routerStateSnapshot = { url: '/admin' } as RouterStateSnapshot;
  });

  it('should allow access to authenticated users', () => {
    authService.isAuthenticated.and.returnValue(true);
    
    const result = guard.canActivate(routeSnapshot, routerStateSnapshot);
    
    expect(result).toBe(true);
    expect(router.navigate).not.toHaveBeenCalled();
  });

  it('should redirect unauthenticated users to login page', () => {
    authService.isAuthenticated.and.returnValue(false);
    
    const result = guard.canActivate(routeSnapshot, routerStateSnapshot);
    
    expect(result).toBe(false);
    expect(router.navigate).toHaveBeenCalledWith(['/login'], { queryParams: { returnUrl: '/admin' } });
  });
});

25.10.4 Testen von Routen mit Parametern

Um Routen mit Parametern zu testen:

import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { of } from 'rxjs';

@Component({
  template: `<div>User ID: {{userId}}</div>`
})
class UserDetailComponent {
  userId: string = '';
  
  constructor(private route: ActivatedRoute) {
    this.route.paramMap.subscribe(params => {
      this.userId = params.get('id') || '';
    });
  }
}

describe('UserDetailComponent', () => {
  let component: UserDetailComponent;
  let fixture: ComponentFixture<UserDetailComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [RouterTestingModule],
      declarations: [UserDetailComponent],
      providers: [
        {
          provide: ActivatedRoute,
          useValue: {
            paramMap: of(convertToParamMap({ id: '123' }))
          }
        }
      ]
    }).compileComponents();

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

  it('should display the user ID from route parameter', () => {
    expect(component.userId).toBe('123');
    expect(fixture.nativeElement.textContent).toContain('User ID: 123');
  });
});

25.10.5 Testen von Resolver

Router-Resolver können wie folgt getestet werden:

import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { UserDataResolver } from './user-data.resolver';
import { UserService } from './user.service';
import { of } from 'rxjs';

describe('UserDataResolver', () => {
  let resolver: UserDataResolver;
  let userService: jasmine.SpyObj<UserService>;
  let routeSnapshot: ActivatedRouteSnapshot;
  let routerStateSnapshot: RouterStateSnapshot;

  beforeEach(() => {
    const userServiceSpy = jasmine.createSpyObj('UserService', ['getUserById']);

    TestBed.configureTestingModule({
      imports: [RouterTestingModule],
      providers: [
        UserDataResolver,
        { provide: UserService, useValue: userServiceSpy }
      ]
    });

    resolver = TestBed.inject(UserDataResolver);
    userService = TestBed.inject(UserService) as jasmine.SpyObj<UserService>;
    
    routeSnapshot = new ActivatedRouteSnapshot();
    routeSnapshot.params = { id: '123' };
    routerStateSnapshot = { url: '/users/123' } as RouterStateSnapshot;
  });

  it('should resolve user data from service', () => {
    const mockUser = { id: '123', name: 'Test User' };
    userService.getUserById.and.returnValue(of(mockUser));
    
    resolver.resolve(routeSnapshot, routerStateSnapshot).subscribe(result => {
      expect(result).toEqual(mockUser);
    });
    
    expect(userService.getUserById).toHaveBeenCalledWith('123');
  });
});

25.10.6 Testen der Navigation bei Formulareinreichung

Ein praktisches Beispiel mit Formulareinreichung und Navigation:

import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { By } from '@angular/platform-browser';

@Component({
  template: `
    <form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
      <input formControlName="username">
      <input type="password" formControlName="password">
      <button type="submit" [disabled]="loginForm.invalid">Login</button>
    </form>
  `
})
class LoginComponent {
  loginForm: FormGroup;
  
  constructor(private fb: FormBuilder, private router: Router) {
    this.loginForm = this.fb.group({
      username: ['', Validators.required],
      password: ['', Validators.required]
    });
  }
  
  onSubmit() {
    if (this.loginForm.valid) {
      // In einer realen App würde hier eine Authentication passieren
      this.router.navigate(['/dashboard']);
    }
  }
}

describe('LoginComponent', () => {
  let component: LoginComponent;
  let fixture: ComponentFixture<LoginComponent>;
  let router: Router;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [ReactiveFormsModule, RouterTestingModule],
      declarations: [LoginComponent]
    }).compileComponents();

    fixture = TestBed.createComponent(LoginComponent);
    component = fixture.componentInstance;
    router = TestBed.inject(Router);
    fixture.detectChanges();
  });

  it('should navigate to dashboard after successful login', () => {
    // Spy auf die navigate-Methode
    spyOn(router, 'navigate');
    
    // Formular ausfüllen
    const usernameInput = fixture.debugElement.query(By.css('input[formControlName="username"]')).nativeElement;
    const passwordInput = fixture.debugElement.query(By.css('input[formControlName="password"]')).nativeElement;
    
    usernameInput.value = 'testuser';
    usernameInput.dispatchEvent(new Event('input'));
    
    passwordInput.value = 'password123';
    passwordInput.dispatchEvent(new Event('input'));
    
    fixture.detectChanges();
    
    // Formular absenden
    const form = fixture.debugElement.query(By.css('form'));
    form.triggerEventHandler('submit', null);
    
    // Überprüfen der Navigation
    expect(router.navigate).toHaveBeenCalledWith(['/dashboard']);
  });
});

25.11 Testing mit RxJS

25.11.1 Grundlagen zum Testen von Observables

RxJS ist ein integraler Bestandteil von Angular-Anwendungen und stellt oft eine Herausforderung beim Testen dar. Es gibt verschiedene Ansätze, um RxJS-Code effektiv zu testen.

25.11.2 Manuelles Testen mit fakeAsync und tick

Der fakeAsync-Wrapper und die tick-Funktion ermöglichen die Kontrolle über die Zeit in Tests:

import { fakeAsync, tick } from '@angular/core/testing';
import { of } from 'rxjs';
import { delay } from 'rxjs/operators';

it('should handle delayed observable', fakeAsync(() => {
  let result: any;
  
  // Observable mit Verzögerung
  of('delayed value').pipe(
    delay(1000)
  ).subscribe(value => {
    result = value;
  });
  
  // Anfangs kein Ergebnis
  expect(result).toBeUndefined();
  
  // Zeit voranschreiten lassen
  tick(1000);
  
  // Nach Verzögerung sollte Wert gesetzt sein
  expect(result).toBe('delayed value');
}));

25.11.3 TestScheduler und Marble Testing

Der TestScheduler ermöglicht präzise Tests mit der “Marble Testing”-Syntax:

import { TestScheduler } from 'rxjs/testing';
import { map, filter, debounceTime } from 'rxjs/operators';

describe('RxJS Operators', () => {
  let testScheduler: TestScheduler;

  beforeEach(() => {
    testScheduler = new TestScheduler((actual, expected) => {
      expect(actual).toEqual(expected);
    });
  });

  it('should transform values with map operator', () => {
    testScheduler.run(({ cold, expectObservable }) => {
      const source = cold('a-b-c|', { a: 1, b: 2, c: 3 });
      const expected = 'a-b-c|';
      const expectedValues = { a: 2, b: 4, c: 6 };
      
      const result = source.pipe(map(x => x * 2));
      
      expectObservable(result).toBe(expected, expectedValues);
    });
  });

  it('should filter values', () => {
    testScheduler.run(({ cold, expectObservable }) => {
      const source = cold('a-b-c-d-e|', { a: 1, b: 2, c: 3, d: 4, e: 5 });
      const expected = 'a---c---e|';
      const expectedValues = { a: 1, c: 3, e: 5 };
      
      const result = source.pipe(filter(x => x % 2 === 1));
      
      expectObservable(result).toBe(expected, expectedValues);
    });
  });

  it('should debounce values', () => {
    testScheduler.run(({ cold, expectObservable }) => {
      const source = cold('a-b-c-----|', { a: 1, b: 2, c: 3 });
      const expected = '-----|---(c|)';
      
      const result = source.pipe(debounceTime(5));
      
      expectObservable(result).toBe(expected, { c: 3 });
    });
  });
});

25.11.3.1 Marble Syntax erklärt

Die Marble-Syntax verwendet spezielle Zeichen, um den zeitlichen Verlauf von Observables darzustellen:

25.11.4 Testen von Service-Methoden mit Observables

import { TestBed } from '@angular/core/testing';
import { DataService } from './data.service';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { of, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';

describe('DataService', () => {
  let service: DataService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [DataService]
    });

    service = TestBed.inject(DataService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpMock.verify(); // Stellt sicher, dass keine unerwarteten Anfragen übrig sind
  });

  it('should handle successful data fetch', () => {
    const mockData = [{ id: 1, name: 'Item 1' }];
    
    // Observable testen
    service.getData().subscribe(data => {
      expect(data).toEqual(mockData);
    });
    
    // HTTP-Anfrage simulieren
    const req = httpMock.expectOne('api/data');
    expect(req.request.method).toBe('GET');
    req.flush(mockData);
  });

  it('should handle errors', () => {
    let errorResponse: any;
    
    service.getData().pipe(
      catchError(error => {
        errorResponse = error;
        return of(null);
      })
    ).subscribe(() => {
      expect(errorResponse.status).toBe(404);
      expect(errorResponse.statusText).toBe('Not Found');
    });
    
    const req = httpMock.expectOne('api/data');
    req.flush('Not found', { status: 404, statusText: 'Not Found' });
  });
});

25.11.5 Testen von Komponenten mit Observables

Beim Testen von Komponenten, die Observables verwenden:

import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { UserListComponent } from './user-list.component';
import { UserService } from './user.service';
import { of } from 'rxjs';
import { delay } from 'rxjs/operators';
import { By } from '@angular/platform-browser';

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 display users after loading', fakeAsync(() => {
    const mockUsers = [
      { id: 1, name: 'User 1' },
      { id: 2, name: 'User 2' }
    ];
    
    // Service-Antwort mit Verzögerung simulieren
    userServiceSpy.getUsers.and.returnValue(of(mockUsers).pipe(delay(500)));
    
    // OnInit auslösen
    fixture.detectChanges();
    
    // Überprüfen, dass Loading-Status korrekt ist
    expect(component.isLoading).toBe(true);
    
    // Zeit voranschreiten lassen
    tick(500);
    fixture.detectChanges();
    
    // Überprüfen, dass Loading-Status aktualisiert wurde
    expect(component.isLoading).toBe(false);
    expect(component.users).toEqual(mockUsers);
    
    // Überprüfen, dass DOM aktualisiert wurde
    const userElements = fixture.debugElement.queryAll(By.css('.user-item'));
    expect(userElements.length).toBe(2);
    expect(userElements[0].nativeElement.textContent).toContain('User 1');
    expect(userElements[1].nativeElement.textContent).toContain('User 2');
  }));
});

25.11.6 Testen von Subject und BehaviorSubject

Für State-Management mit Subjects:

import { TestBed } from '@angular/core/testing';
import { StateService } from './state.service';
import { skip } from 'rxjs/operators';

describe('StateService', () => {
  let service: StateService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [StateService]
    });
    service = TestBed.inject(StateService);
  });

  it('should have initial state', () => {
    service.state$.subscribe(state => {
      expect(state).toEqual({ count: 0, user: null });
    });
  });

  it('should update state', () => {
    let currentState: any;
    
    // Skip initial value, nur an Updates interessiert
    service.state$.pipe(skip(1)).subscribe(state => {
      currentState = state;
    });
    
    service.updateCount(5);
    expect(currentState).toEqual({ count: 5, user: null });
    
    service.updateUser({ id: 1, name: 'Test User' });
    expect(currentState).toEqual({ count: 5, user: { id: 1, name: 'Test User' } });
  });
});

25.11.7 Testen von Kombinationsoperatoren

Für komplexere Observable-Kombinationen:

import { TestScheduler } from 'rxjs/testing';
import { combineLatest, merge, zip, forkJoin } from 'rxjs';

describe('Combination Operators', () => {
  let testScheduler: TestScheduler;

  beforeEach(() => {
    testScheduler = new TestScheduler((actual, expected) => {
      expect(actual).toEqual(expected);
    });
  });

  it('should test combineLatest', () => {
    testScheduler.run(({ cold, expectObservable }) => {
      const a = cold('-a--b--c--|', { a: 1, b: 2, c: 3 });
      const b = cold('--x--y--z--|', { x: 10, y: 20, z: 30 });
      
      const result = combineLatest([a, b]).pipe(
        map(([aValue, bValue]) => aValue + bValue)
      );
      
      const expected = '--A--B-CD-|';
      const expectedValues = {
        A: 1 + 10,  // a + x
        B: 2 + 10,  // b + x
        C: 2 + 20,  // b + y
        D: 3 + 20,  // c + y
        E: 3 + 30   // c + z
      };
      
      expectObservable(result).toBe(expected, expectedValues);
    });
  });
});

25.11.8 Testen der Abbruchlogik (Unsubscribe)

Es ist wichtig, die korrekte Bereinigung von Subscriptions zu testen:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DataComponent } from './data.component';
import { DataService } from './data.service';
import { Subject, of } from 'rxjs';
import { delay } from 'rxjs/operators';

describe('DataComponent', () => {
  let component: DataComponent;
  let fixture: ComponentFixture<DataComponent>;
  let dataSubject: Subject<any>;
  let dataServiceSpy: jasmine.SpyObj<DataService>;

  beforeEach(async () => {
    dataSubject = new Subject();
    const spy = jasmine.createSpyObj('DataService', ['getData']);
    spy.getData.and.returnValue(dataSubject.asObservable());
    
    await TestBed.configureTestingModule({
      declarations: [DataComponent],
      providers: [
        { provide: DataService, useValue: spy }
      ]
    }).compileComponents();

    fixture = TestBed.createComponent(DataComponent);
    component = fixture.componentInstance;
    dataServiceSpy = TestBed.inject(DataService) as jasmine.SpyObj<DataService>;
    fixture.detectChanges();
  });

  it('should unsubscribe when destroyed', () => {
    // Spion für unsubscribe
    spyOn(component.subscription, 'unsubscribe').and.callThrough();
    
    // Komponente zerstören
    fixture.destroy();
    
    // Überprüfen, dass unsubscribe aufgerufen wurde
    expect(component.subscription.unsubscribe).toHaveBeenCalled();
  });
});

25.11.9 Fortgeschrittene Szenarien mit switchMap, mergeMap, etc.

import { TestScheduler } from 'rxjs/testing';
import { switchMap, mergeMap, concatMap, exhaustMap } from 'rxjs/operators';
import { of } from 'rxjs';

describe('Mapping Operators', () => {
  let testScheduler: TestScheduler;

  beforeEach(() => {
    testScheduler = new TestScheduler((actual, expected) => {
      expect(actual).toEqual(expected);
    });
  });

  it('should test switchMap behavior', () => {
    testScheduler.run(({ cold, hot, expectObservable }) => {
      const source = hot('-a-b---c--|');
      const innerA = cold('--x--y--|', { x: 'x from a', y: 'y from a' });
      const innerB = cold('----z--|', { z: 'z from b' });
      const innerC = cold('--w--|', { w: 'w from c' });
      
      const result = source.pipe(
        switchMap(value => {
          if (value === 'a') return innerA;
          if (value === 'b') return innerB;
          return innerC;
        })
      );
      
      // switchMap bricht vorherige innere Observables ab
      const expected = '----x--y-z----w--|';
      
      expectObservable(result).toBe(expected, {
        x: 'x from a',
        y: 'y from a',
        z: 'z from b',
        w: 'w from c'
      });
    });
  });
});

25.11.10 Fehlerbehandlung mit catchError und retry

import { TestScheduler } from 'rxjs/testing';
import { catchError, retry } from 'rxjs/operators';
import { of, throwError } from 'rxjs';

describe('Error Handling Operators', () => {
  let testScheduler: TestScheduler;

  beforeEach(() => {
    testScheduler = new TestScheduler((actual, expected) => {
      expect(actual).toEqual(expected);
    });
  });

  it('should test catchError', () => {
    testScheduler.run(({ cold, expectObservable }) => {
      const source = cold('-a-b-#', { a: 1, b: 2 }, 'error message');
      const result = source.pipe(
        catchError(err => of('fallback'))
      );
      
      const expected = '-a-b-f';
      const expectedValues = { a: 1, b: 2, f: 'fallback' };
      
      expectObservable(result).toBe(expected, expectedValues);
    });
  });

  it('should test retry', () => {
    testScheduler.run(({ cold, expectObservable }) => {
      let retryCount = 0;
      
      const source = cold('-a-b-#', { a: 1, b: 2 }, 'error message').pipe(
        retry(2),
        catchError(err => of('failed after retries'))
      );
      
      // Original + 2 Retries, dann Fallback
      const expected = '-a-b--a-b--a-b-f';
      const expectedValues = { a: 1, b: 2, f: 'failed after retries' };
      
      expectObservable(source).toBe(expected, expectedValues);
    });
  });
});