16 Testing in NestJS

Testing bildet das Fundament für robuste und wartbare Software. Wenn wir an Testing denken, sollten wir es nicht als lästige Pflicht betrachten, sondern als kraftvolles Werkzeug, das uns dabei hilft, Vertrauen in unseren Code zu entwickeln und gleichzeitig die Entwicklungsgeschwindigkeit zu erhöhen. NestJS macht Testing zu einem integralen Bestandteil des Entwicklungsprozesses und bietet uns dabei ausgezeichnete Werkzeuge und Patterns.

16.1 Einführung in das NestJS-Testing

Bevor wir uns in die technischen Details vertiefen, sollten wir verstehen, warum Testing in modernen Anwendungen so wichtig ist. Stellen Sie sich vor, Sie bauen ein Haus. Würden Sie das Fundament legen, ohne zu prüfen, ob es stabil ist? Würden Sie die Elektrik installieren, ohne zu testen, ob der Strom fließt? Genauso verhält es sich mit Software. Tests sind unser Werkzeug, um sicherzustellen, dass jeder Teil unserer Anwendung korrekt funktioniert.

16.1.1 Die Testing-Pyramide verstehen

Die Testing-Pyramide ist ein bewährtes Konzept, das uns dabei hilft, eine ausgewogene Test-Strategie zu entwickeln. Stellen Sie sich eine Pyramide vor, die aus drei Schichten besteht:

An der Basis finden wir Unit-Tests. Diese sind schnell, isoliert und testen einzelne Funktionen oder Klassen. Sie bilden das Fundament unserer Test-Suite und sollten den größten Anteil ausmachen. Denken Sie an Unit-Tests wie an die Überprüfung einzelner Bausteine vor dem Zusammenbau.

In der Mitte befinden sich Integration Tests. Diese testen das Zusammenspiel mehrerer Komponenten. Sie sind langsamer als Unit-Tests, aber sie geben uns Vertrauen, dass verschiedene Teile unserer Anwendung korrekt zusammenarbeiten.

An der Spitze stehen End-to-End Tests (E2E). Diese testen die gesamte Anwendung aus Benutzersicht. Sie sind am langsamsten, aber sie geben uns die höchste Sicherheit, dass die Anwendung als Ganzes funktioniert.

16.1.2 Testing-Philosophie in NestJS

NestJS fördert eine Test-First-Mentalität. Das Framework ist so gestaltet, dass jede Komponente testbar ist. Dependency Injection, Modularität und klare Trennungen machen es einfach, Tests zu schreiben. Wenn Sie feststellen, dass eine Komponente schwer zu testen ist, ist das oft ein Zeichen dafür, dass das Design verbessert werden könnte.

16.2 Das Testing-Framework

NestJS nutzt Jest als Standard-Testing-Framework, ergänzt durch Supertest für HTTP-Tests. Diese Kombination bietet uns alles, was wir für umfassende Tests benötigen.

16.2.1 Jest als Standard

Jest ist nicht nur ein Test-Runner, sondern ein komplettes Testing-Ökosystem. Es bietet uns Test-Runner, Assertion-Library, Mocking-Funktionen und Code-Coverage-Reports in einem Paket. Denken Sie an Jest als Ihren Testing-Schweizer Taschenmesser.

// jest.config.js - Grundkonfiguration für NestJS
module.exports = {
  // Verwende die NestJS Jest-Presets für optimale Konfiguration
  preset: '@nestjs/testing',
  
  // Definiere welche Dateien als Tests erkannt werden
  testMatch: [
    '**/__tests__/**/*.(test|spec).ts',
    '**/*.(test|spec).ts'
  ],
  
  // Sammle Coverage-Informationen von diesen Dateien
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.spec.ts',
    '!src/**/*.interface.ts',
    '!src/main.ts',
  ],
  
  // Coverage-Schwellenwerte definieren
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
  
  // Test-Umgebung konfigurieren
  testEnvironment: 'node',
  
  // Setup-Dateien für Tests
  setupFilesAfterEnv: ['<rootDir>/test/setup.ts'],
  
  // Module-Pfade für einfachere Imports
  moduleNameMapping: {
    '^src/(.*)$': '<rootDir>/src/$1',
  },
};

16.2.2 Supertest für HTTP-Tests

Supertest ist unser Werkzeug für das Testen von HTTP-Endpunkten. Es ermöglicht uns, HTTP-Requests zu simulieren und die Responses zu validieren. Stellen Sie sich Supertest als einen sehr geduldigen Benutzer vor, der systematisch alle Endpunkte Ihrer API testet.

// Beispiel für einen einfachen Supertest
import * as request from 'supertest';
import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';

describe('API Integration Tests', () => {
  let app: INestApplication;

  beforeAll(async () => {
    // Hier erstellen wir eine Test-Instanz unserer Anwendung
    const moduleRef = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleRef.createNestApplication();
    await app.init();
  });

  afterAll(async () => {
    // Wichtig: Ressourcen nach Tests aufräumen
    await app.close();
  });

  it('should respond with health check', () => {
    // Mit Supertest senden wir einen GET-Request und prüfen die Antwort
    return request(app.getHttpServer())
      .get('/health')
      .expect(200)
      .expect((response) => {
        expect(response.body).toHaveProperty('status', 'ok');
      });
  });
});

16.2.3 Test-Module und TestingModule

Das TestingModule ist das Herzstück des NestJS-Testing-Frameworks. Es ermöglicht uns, isolierte Test-Umgebungen zu erstellen, in denen wir Dependencies mocken und Komponenten unabhängig testen können.

import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { getRepositoryToken } from '@nestjs/typeorm';

describe('UsersService', () => {
  let service: UsersService;
  let repository: Repository<User>;

  beforeEach(async () => {
    // Erstelle ein isoliertes Testing-Modul
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          // Mocke das Repository für isolierte Tests
          provide: getRepositoryToken(User),
          useValue: {
            find: jest.fn(),
            findOne: jest.fn(),
            save: jest.fn(),
            create: jest.fn(),
            delete: jest.fn(),
          },
        },
      ],
    }).compile();

    // Extrahiere die Services aus dem Test-Modul
    service = module.get<UsersService>(UsersService);
    repository = module.get<Repository<User>>(getRepositoryToken(User));
  });

  // Hier würden unsere Tests folgen...
});

16.3 Arten von Tests

Lassen Sie uns die verschiedenen Arten von Tests betrachten und verstehen, wann und wie wir sie einsetzen.

16.3.1 Unit-Tests

Unit-Tests sind die Grundbausteine unserer Test-Suite. Sie testen einzelne Funktionen oder Methoden isoliert von ihren Dependencies. Denken Sie an Unit-Tests wie an die Überprüfung einzelner Zahnräder einer Uhr, bevor Sie sie zusammenbauen.

import { UsersService } from './users.service';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { NotFoundException, ConflictException } from '@nestjs/common';

describe('UsersService Unit Tests', () => {
  let service: UsersService;
  let mockRepository: jest.Mocked<Repository<User>>;

  beforeEach(async () => {
    // Erstelle Mock-Implementierungen für alle Repository-Methoden
    mockRepository = {
      find: jest.fn(),
      findOne: jest.fn(),
      save: jest.fn(),
      create: jest.fn(),
      delete: jest.fn(),
    } as any;

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: getRepositoryToken(User),
          useValue: mockRepository,
        },
      ],
    }).compile();

    service = module.get<UsersService>(UsersService);
  });

  describe('findById', () => {
    it('should return a user when found', async () => {
      // Arrange: Bereite Test-Daten vor
      const userId = '123';
      const expectedUser = {
        id: userId,
        email: 'test@example.com',
        firstName: 'John',
        lastName: 'Doe',
      };

      // Konfiguriere das Mock-Verhalten
      mockRepository.findOne.mockResolvedValue(expectedUser);

      // Act: Führe die zu testende Aktion aus
      const result = await service.findById(userId);

      // Assert: Überprüfe das Ergebnis
      expect(result).toEqual(expectedUser);
      expect(mockRepository.findOne).toHaveBeenCalledWith({ where: { id: userId } });
    });

    it('should throw NotFoundException when user not found', async () => {
      // Arrange: Simuliere, dass kein User gefunden wird
      const userId = 'non-existing-id';
      mockRepository.findOne.mockResolvedValue(null);

      // Act & Assert: Überprüfe, dass die richtige Exception geworfen wird
      await expect(service.findById(userId)).rejects.toThrow(NotFoundException);
      expect(mockRepository.findOne).toHaveBeenCalledWith({ where: { id: userId } });
    });
  });

  describe('create', () => {
    it('should successfully create a new user', async () => {
      // Arrange
      const createUserDto = {
        email: 'new@example.com',
        firstName: 'Jane',
        lastName: 'Smith',
        password: 'securePassword123',
      };

      const createdUser = {
        id: '456',
        ...createUserDto,
        createdAt: new Date(),
        updatedAt: new Date(),
      };

      // Simuliere, dass die E-Mail noch nicht existiert
      mockRepository.findOne.mockResolvedValue(null);
      mockRepository.create.mockReturnValue(createdUser);
      mockRepository.save.mockResolvedValue(createdUser);

      // Act
      const result = await service.create(createUserDto);

      // Assert
      expect(result).toEqual(createdUser);
      expect(mockRepository.findOne).toHaveBeenCalledWith({ 
        where: { email: createUserDto.email } 
      });
      expect(mockRepository.create).toHaveBeenCalledWith(createUserDto);
      expect(mockRepository.save).toHaveBeenCalledWith(createdUser);
    });

    it('should throw ConflictException when email already exists', async () => {
      // Arrange
      const createUserDto = {
        email: 'existing@example.com',
        firstName: 'Jane',
        lastName: 'Smith',
        password: 'securePassword123',
      };

      const existingUser = { id: '789', email: createUserDto.email };
      mockRepository.findOne.mockResolvedValue(existingUser);

      // Act & Assert
      await expect(service.create(createUserDto)).rejects.toThrow(ConflictException);
      expect(mockRepository.findOne).toHaveBeenCalledWith({ 
        where: { email: createUserDto.email } 
      });
      // Stelle sicher, dass save nicht aufgerufen wird
      expect(mockRepository.save).not.toHaveBeenCalled();
    });
  });
});

16.3.2 Integration Tests

Integration Tests überprüfen das Zusammenspiel mehrerer Komponenten. Sie sind besonders wertvoll, um sicherzustellen, dass verschiedene Services korrekt miteinander kommunizieren.

import { Test, TestingModule } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersModule } from './users.module';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
import { Repository } from 'typeorm';
import { getRepositoryToken } from '@nestjs/typeorm';

describe('Users Integration Tests', () => {
  let module: TestingModule;
  let service: UsersService;
  let repository: Repository<User>;

  beforeAll(async () => {
    // Erstelle ein Test-Modul mit echter Datenbank-Verbindung
    module = await Test.createTestingModule({
      imports: [
        // Verwende SQLite In-Memory-Datenbank für Tests
        TypeOrmModule.forRoot({
          type: 'sqlite',
          database: ':memory:',
          entities: [User],
          synchronize: true,
          logging: false,
        }),
        UsersModule,
      ],
    }).compile();

    service = module.get<UsersService>(UsersService);
    repository = module.get<Repository<User>>(getRepositoryToken(User));
  });

  afterAll(async () => {
    await module.close();
  });

  beforeEach(async () => {
    // Räume die Datenbank vor jedem Test auf
    await repository.clear();
  });

  describe('User CRUD Operations', () => {
    it('should create, find, update and delete a user', async () => {
      // Create - Erstelle einen neuen User
      const createDto = {
        email: 'integration@test.com',
        firstName: 'Integration',
        lastName: 'Test',
        password: 'password123',
      };

      const createdUser = await service.create(createDto);
      expect(createdUser).toBeDefined();
      expect(createdUser.email).toBe(createDto.email);

      // Read - Finde den erstellten User
      const foundUser = await service.findById(createdUser.id);
      expect(foundUser).toBeDefined();
      expect(foundUser.email).toBe(createDto.email);

      // Update - Aktualisiere den User
      const updateDto = { firstName: 'Updated' };
      const updatedUser = await service.update(createdUser.id, updateDto);
      expect(updatedUser.firstName).toBe('Updated');

      // Delete - Lösche den User
      await service.remove(createdUser.id);
      
      // Verifiziere, dass der User gelöscht wurde
      await expect(service.findById(createdUser.id)).rejects.toThrow();
    });

    it('should handle concurrent user creation correctly', async () => {
      // Teste, wie sich die Anwendung bei gleichzeitigen Operationen verhält
      const createDto1 = {
        email: 'concurrent1@test.com',
        firstName: 'User',
        lastName: 'One',
        password: 'password123',
      };

      const createDto2 = {
        email: 'concurrent2@test.com',
        firstName: 'User',
        lastName: 'Two',
        password: 'password123',
      };

      // Führe beide Operationen gleichzeitig aus
      const [user1, user2] = await Promise.all([
        service.create(createDto1),
        service.create(createDto2),
      ]);

      expect(user1.email).toBe(createDto1.email);
      expect(user2.email).toBe(createDto2.email);
      expect(user1.id).not.toBe(user2.id);
    });
  });
});

16.3.3 End-to-End-Tests

End-to-End Tests simulieren echte Benutzerinteraktionen und testen die gesamte Anwendung von Anfang bis Ende. Sie geben uns das höchste Vertrauen, dass unsere Anwendung korrekt funktioniert.

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { TypeOrmModule } from '@nestjs/typeorm';

describe('Users E2E Tests', () => {
  let app: INestApplication;
  let authToken: string;

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    })
    // Überschreibe die Datenbank-Konfiguration für Tests
    .overrideModule(TypeOrmModule)
    .useModule(
      TypeOrmModule.forRoot({
        type: 'sqlite',
        database: ':memory:',
        autoLoadEntities: true,
        synchronize: true,
      })
    )
    .compile();

    app = moduleFixture.createNestApplication();
    
    // Konfiguriere die Anwendung wie in der echten Umgebung
    app.useGlobalPipes(new ValidationPipe());
    app.useGlobalInterceptors(new TransformInterceptor());
    
    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  describe('Authentication Flow', () => {
    it('should register a new user and login', async () => {
      const registerDto = {
        email: 'e2e@test.com',
        firstName: 'E2E',
        lastName: 'Test',
        password: 'strongPassword123!',
      };

      // Schritt 1: Registriere einen neuen User
      const registerResponse = await request(app.getHttpServer())
        .post('/auth/register')
        .send(registerDto)
        .expect(201);

      expect(registerResponse.body).toHaveProperty('id');
      expect(registerResponse.body.email).toBe(registerDto.email);

      // Schritt 2: Logge den User ein
      const loginResponse = await request(app.getHttpServer())
        .post('/auth/login')
        .send({
          email: registerDto.email,
          password: registerDto.password,
        })
        .expect(200);

      expect(loginResponse.body).toHaveProperty('accessToken');
      authToken = loginResponse.body.accessToken;

      // Schritt 3: Zugriff auf geschützte Route
      const profileResponse = await request(app.getHttpServer())
        .get('/auth/profile')
        .set('Authorization', `Bearer ${authToken}`)
        .expect(200);

      expect(profileResponse.body.email).toBe(registerDto.email);
    });

    it('should reject invalid credentials', async () => {
      await request(app.getHttpServer())
        .post('/auth/login')
        .send({
          email: 'nonexistent@test.com',
          password: 'wrongPassword',
        })
        .expect(401);
    });
  });

  describe('User Management', () => {
    beforeEach(async () => {
      // Stelle sicher, dass wir für jeden Test authentifiziert sind
      if (!authToken) {
        const loginResponse = await request(app.getHttpServer())
          .post('/auth/login')
          .send({
            email: 'e2e@test.com',
            password: 'strongPassword123!',
          });
        authToken = loginResponse.body.accessToken;
      }
    });

    it('should get user profile', async () => {
      const response = await request(app.getHttpServer())
        .get('/users/profile')
        .set('Authorization', `Bearer ${authToken}`)
        .expect(200);

      expect(response.body).toHaveProperty('email');
      expect(response.body).not.toHaveProperty('password');
    });

    it('should update user profile', async () => {
      const updateDto = {
        firstName: 'Updated',
        lastName: 'Name',
      };

      const response = await request(app.getHttpServer())
        .patch('/users/profile')
        .set('Authorization', `Bearer ${authToken}`)
        .send(updateDto)
        .expect(200);

      expect(response.body.firstName).toBe(updateDto.firstName);
      expect(response.body.lastName).toBe(updateDto.lastName);
    });

    it('should validate input data', async () => {
      const invalidDto = {
        email: 'invalid-email', // Ungültige E-Mail
        firstName: '', // Leerer Name
      };

      const response = await request(app.getHttpServer())
        .patch('/users/profile')
        .set('Authorization', `Bearer ${authToken}`)
        .send(invalidDto)
        .expect(400);

      expect(response.body).toHaveProperty('message');
      expect(Array.isArray(response.body.message)).toBe(true);
    });
  });
});

16.4 Controller-Tests

Controller sind die Eingangspunkte unserer API und verdienen besondere Aufmerksamkeit beim Testen. Wir müssen sicherstellen, dass sie HTTP-Requests korrekt verarbeiten und angemessen antworten.

16.4.1 Testen von HTTP-Endpunkten

import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { NotFoundException } from '@nestjs/common';

describe('UsersController', () => {
  let controller: UsersController;
  let service: UsersService;

  // Mock für den UsersService
  const mockUsersService = {
    create: jest.fn(),
    findAll: jest.fn(),
    findOne: jest.fn(),
    update: jest.fn(),
    remove: jest.fn(),
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [
        {
          provide: UsersService,
          useValue: mockUsersService,
        },
      ],
    }).compile();

    controller = module.get<UsersController>(UsersController);
    service = module.get<UsersService>(UsersService);
  });

  afterEach(() => {
    // Räume die Mocks nach jedem Test auf
    jest.clearAllMocks();
  });

  describe('create', () => {
    it('should successfully create a user', async () => {
      // Arrange
      const createUserDto: CreateUserDto = {
        email: 'test@example.com',
        firstName: 'John',
        lastName: 'Doe',
        password: 'password123',
      };

      const expectedResult = {
        id: '1',
        ...createUserDto,
        createdAt: new Date(),
        updatedAt: new Date(),
      };

      mockUsersService.create.mockResolvedValue(expectedResult);

      // Act
      const result = await controller.create(createUserDto);

      // Assert
      expect(result).toEqual(expectedResult);
      expect(service.create).toHaveBeenCalledWith(createUserDto);
      expect(service.create).toHaveBeenCalledTimes(1);
    });

    it('should handle service errors appropriately', async () => {
      // Arrange
      const createUserDto: CreateUserDto = {
        email: 'test@example.com',
        firstName: 'John',
        lastName: 'Doe',
        password: 'password123',
      };

      mockUsersService.create.mockRejectedValue(new Error('Database error'));

      // Act & Assert
      await expect(controller.create(createUserDto)).rejects.toThrow('Database error');
      expect(service.create).toHaveBeenCalledWith(createUserDto);
    });
  });

  describe('findAll', () => {
    it('should return an array of users', async () => {
      // Arrange
      const expectedUsers = [
        { id: '1', email: 'user1@test.com', firstName: 'User', lastName: 'One' },
        { id: '2', email: 'user2@test.com', firstName: 'User', lastName: 'Two' },
      ];

      mockUsersService.findAll.mockResolvedValue(expectedUsers);

      // Act
      const result = await controller.findAll();

      // Assert
      expect(result).toEqual(expectedUsers);
      expect(service.findAll).toHaveBeenCalledTimes(1);
    });
  });

  describe('findOne', () => {
    it('should return a user by id', async () => {
      // Arrange
      const userId = '1';
      const expectedUser = {
        id: userId,
        email: 'test@example.com',
        firstName: 'John',
        lastName: 'Doe',
      };

      mockUsersService.findOne.mockResolvedValue(expectedUser);

      // Act
      const result = await controller.findOne(userId);

      // Assert
      expect(result).toEqual(expectedUser);
      expect(service.findOne).toHaveBeenCalledWith(userId);
    });

    it('should throw NotFoundException when user does not exist', async () => {
      // Arrange
      const userId = 'non-existent';
      mockUsersService.findOne.mockRejectedValue(new NotFoundException('User not found'));

      // Act & Assert
      await expect(controller.findOne(userId)).rejects.toThrow(NotFoundException);
      expect(service.findOne).toHaveBeenCalledWith(userId);
    });
  });

  describe('update', () => {
    it('should update and return the user', async () => {
      // Arrange
      const userId = '1';
      const updateUserDto: UpdateUserDto = {
        firstName: 'Updated',
        lastName: 'User',
      };

      const expectedResult = {
        id: userId,
        email: 'test@example.com',
        ...updateUserDto,
        updatedAt: new Date(),
      };

      mockUsersService.update.mockResolvedValue(expectedResult);

      // Act
      const result = await controller.update(userId, updateUserDto);

      // Assert
      expect(result).toEqual(expectedResult);
      expect(service.update).toHaveBeenCalledWith(userId, updateUserDto);
    });
  });

  describe('remove', () => {
    it('should delete a user', async () => {
      // Arrange
      const userId = '1';
      mockUsersService.remove.mockResolvedValue(undefined);

      // Act
      await controller.remove(userId);

      // Assert
      expect(service.remove).toHaveBeenCalledWith(userId);
    });
  });
});

16.4.2 Mocking von Services

Das Mocken von Services ist eine Kunst für sich. Wir müssen verstehen, welche Abhängigkeiten wir isolieren wollen und wie wir realistische Mock-Verhalten erstellen.

// Erweiterte Service-Mocking-Strategien
describe('UsersController with Advanced Mocking', () => {
  let controller: UsersController;
  let usersService: jest.Mocked<UsersService>;
  let emailService: jest.Mocked<EmailService>;

  beforeEach(async () => {
    // Erstelle typisierte Mocks für bessere IntelliSense
    const mockUsersService = {
      create: jest.fn(),
      findAll: jest.fn(),
      findOne: jest.fn(),
      update: jest.fn(),
      remove: jest.fn(),
      findByEmail: jest.fn(),
    } as jest.Mocked<UsersService>;

    const mockEmailService = {
      sendWelcomeEmail: jest.fn(),
      sendPasswordResetEmail: jest.fn(),
    } as jest.Mocked<EmailService>;

    const module: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [
        { provide: UsersService, useValue: mockUsersService },
        { provide: EmailService, useValue: mockEmailService },
      ],
    }).compile();

    controller = module.get<UsersController>(UsersController);
    usersService = module.get(UsersService);
    emailService = module.get(EmailService);
  });

  describe('create with email notification', () => {
    it('should create user and send welcome email', async () => {
      // Arrange
      const createUserDto: CreateUserDto = {
        email: 'new@example.com',
        firstName: 'New',
        lastName: 'User',
        password: 'password123',
      };

      const createdUser = { id: '1', ...createUserDto };
      
      usersService.create.mockResolvedValue(createdUser);
      emailService.sendWelcomeEmail.mockResolvedValue(undefined);

      // Act
      const result = await controller.create(createUserDto);

      // Assert
      expect(result).toEqual(createdUser);
      expect(usersService.create).toHaveBeenCalledWith(createUserDto);
      expect(emailService.sendWelcomeEmail).toHaveBeenCalledWith(createdUser.email, createdUser.firstName);
    });

    it('should create user even if email sending fails', async () => {
      // Arrange
      const createUserDto: CreateUserDto = {
        email: 'new@example.com',
        firstName: 'New',
        lastName: 'User',
        password: 'password123',
      };

      const createdUser = { id: '1', ...createUserDto };
      
      usersService.create.mockResolvedValue(createdUser);
      emailService.sendWelcomeEmail.mockRejectedValue(new Error('Email service unavailable'));

      // Act
      const result = await controller.create(createUserDto);

      // Assert
      expect(result).toEqual(createdUser);
      expect(usersService.create).toHaveBeenCalledWith(createUserDto);
      expect(emailService.sendWelcomeEmail).toHaveBeenCalled();
    });
  });
});

16.4.3 Request/Response-Tests

Realistische Request/Response-Tests helfen uns sicherzustellen, dass unsere API korrekt mit HTTP-Protokoll umgeht.

import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { ValidationPipe } from '@nestjs/common';

describe('Users API (Request/Response)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [UsersModule],
    })
    .overrideProvider(UsersService)
    .useValue({
      create: jest.fn(),
      findAll: jest.fn(),
      findOne: jest.fn(),
      update: jest.fn(),
      remove: jest.fn(),
    })
    .compile();

    app = moduleRef.createNestApplication();
    
    // Konfiguriere die App wie in Production
    app.useGlobalPipes(new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    }));

    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  describe('POST /users', () => {
    it('should create a user with valid data', async () => {
      const createUserDto = {
        email: 'valid@example.com',
        firstName: 'Valid',
        lastName: 'User',
        password: 'strongPassword123!',
      };

      const mockCreatedUser = { id: '1', ...createUserDto };
      
      const usersService = app.get(UsersService);
      jest.spyOn(usersService, 'create').mockResolvedValue(mockCreatedUser);

      const response = await request(app.getHttpServer())
        .post('/users')
        .send(createUserDto)
        .expect(201);

      expect(response.body).toMatchObject({
        id: '1',
        email: createUserDto.email,
        firstName: createUserDto.firstName,
        lastName: createUserDto.lastName,
      });

      // Passwort sollte nicht in der Response enthalten sein
      expect(response.body).not.toHaveProperty('password');
    });

    it('should reject invalid email format', async () => {
      const invalidDto = {
        email: 'invalid-email', // Falsche E-Mail-Format
        firstName: 'Valid',
        lastName: 'User',
        password: 'strongPassword123!',
      };

      const response = await request(app.getHttpServer())
        .post('/users')
        .send(invalidDto)
        .expect(400);

      expect(response.body).toHaveProperty('message');
      expect(response.body.message).toContain('email');
    });

    it('should reject weak passwords', async () => {
      const weakPasswordDto = {
        email: 'valid@example.com',
        firstName: 'Valid',
        lastName: 'User',
        password: '123', // Zu schwaches Passwort
      };

      const response = await request(app.getHttpServer())
        .post('/users')
        .send(weakPasswordDto)
        .expect(400);

      expect(response.body.message).toContain('password');
    });

    it('should handle content-type properly', async () => {
      const createUserDto = {
        email: 'valid@example.com',
        firstName: 'Valid',
        lastName: 'User',
        password: 'strongPassword123!',
      };

      // Test mit falschem Content-Type
      await request(app.getHttpServer())
        .post('/users')
        .set('Content-Type', 'text/plain')
        .send(JSON.stringify(createUserDto))
        .expect(400);

      // Test mit korrektem Content-Type
      await request(app.getHttpServer())
        .post('/users')
        .set('Content-Type', 'application/json')
        .send(createUserDto)
        .expect(201);
    });
  });

  describe('GET /users/:id', () => {
    it('should return user with correct headers', async () => {
      const userId = '1';
      const mockUser = {
        id: userId,
        email: 'test@example.com',
        firstName: 'Test',
        lastName: 'User',
      };

      const usersService = app.get(UsersService);
      jest.spyOn(usersService, 'findOne').mockResolvedValue(mockUser);

      const response = await request(app.getHttpServer())
        .get(`/users/${userId}`)
        .expect(200);

      expect(response.body).toEqual(mockUser);
      expect(response.headers['content-type']).toMatch(/application\/json/);
    });

    it('should return 404 for non-existent user', async () => {
      const userId = 'non-existent';
      
      const usersService = app.get(UsersService);
      jest.spyOn(usersService, 'findOne').mockRejectedValue(new NotFoundException('User not found'));

      const response = await request(app.getHttpServer())
        .get(`/users/${userId}`)
        .expect(404);

      expect(response.body).toHaveProperty('message', 'User not found');
    });
  });
});

16.5 Service-Tests

Services enthalten die Geschäftslogik unserer Anwendung und sind daher besonders wichtig zu testen. Sie sind oft komplex und haben viele Abhängigkeiten.

16.5.1 Testen von Business Logic

import { Test, TestingModule } from '@nestjs/testing';
import { OrdersService } from './orders.service';
import { Order } from './entities/order.entity';
import { User } from '../users/entities/user.entity';
import { Product } from '../products/entities/product.entity';
import { Repository } from 'typeorm';
import { getRepositoryToken } from '@nestjs/typeorm';
import { PaymentService } from '../payment/payment.service';
import { EmailService } from '../email/email.service';
import { BadRequestException, InternalServerErrorException } from '@nestjs/common';

describe('OrdersService Business Logic', () => {
  let service: OrdersService;
  let orderRepository: jest.Mocked<Repository<Order>>;
  let paymentService: jest.Mocked<PaymentService>;
  let emailService: jest.Mocked<EmailService>;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        OrdersService,
        {
          provide: getRepositoryToken(Order),
          useValue: {
            create: jest.fn(),
            save: jest.fn(),
            find: jest.fn(),
            findOne: jest.fn(),
            createQueryBuilder: jest.fn(),
          },
        },
        {
          provide: PaymentService,
          useValue: {
            processPayment: jest.fn(),
            refundPayment: jest.fn(),
          },
        },
        {
          provide: EmailService,
          useValue: {
            sendOrderConfirmation: jest.fn(),
            sendOrderCancellation: jest.fn(),
          },
        },
      ],
    }).compile();

    service = module.get<OrdersService>(OrdersService);
    orderRepository = module.get(getRepositoryToken(Order));
    paymentService = module.get(PaymentService);
    emailService = module.get(EmailService);
  });

  describe('createOrder', () => {
    it('should create order with valid items and process payment', async () => {
      // Arrange
      const user: User = {
        id: '1',
        email: 'customer@example.com',
        firstName: 'John',
        lastName: 'Doe',
      } as User;

      const orderItems = [
        { productId: '1', quantity: 2, price: 29.99 },
        { productId: '2', quantity: 1, price: 49.99 },
      ];

      const expectedTotal = (29.99 * 2) + (49.99 * 1); // 109.97

      const mockOrder = {
        id: 'order-1',
        user,
        items: orderItems,
        total: expectedTotal,
        status: 'confirmed',
      } as Order;

      // Mock-Verhalten konfigurieren
      orderRepository.create.mockReturnValue(mockOrder);
      orderRepository.save.mockResolvedValue(mockOrder);
      paymentService.processPayment.mockResolvedValue({
        success: true,
        transactionId: 'txn-123',
      });
      emailService.sendOrderConfirmation.mockResolvedValue(undefined);

      // Act
      const result = await service.createOrder(user, orderItems);

      // Assert
      expect(result).toEqual(mockOrder);
      expect(orderRepository.create).toHaveBeenCalledWith({
        user,
        items: orderItems,
        total: expectedTotal,
        status: 'pending',
      });

      expect(paymentService.processPayment).toHaveBeenCalledWith({
        amount: expectedTotal,
        currency: 'EUR',
        customerId: user.id,
      });

      expect(emailService.sendOrderConfirmation).toHaveBeenCalledWith(
        user.email,
        mockOrder
      );
    });

    it('should handle payment failure correctly', async () => {
      // Arrange
      const user: User = { id: '1', email: 'customer@example.com' } as User;
      const orderItems = [{ productId: '1', quantity: 1, price: 29.99 }];

      const mockOrder = {
        id: 'order-1',
        user,
        items: orderItems,
        total: 29.99,
        status: 'pending',
      } as Order;

      orderRepository.create.mockReturnValue(mockOrder);
      orderRepository.save.mockResolvedValue(mockOrder);
      
      // Simuliere Zahlungsfehler
      paymentService.processPayment.mockResolvedValue({
        success: false,
        error: 'Insufficient funds',
      });

      // Act & Assert
      await expect(service.createOrder(user, orderItems)).rejects.toThrow(BadRequestException);

      // Überprüfe, dass die Bestellung als fehlgeschlagen markiert wird
      expect(orderRepository.save).toHaveBeenCalledWith(
        expect.objectContaining({ status: 'failed' })
      );

      // E-Mail sollte nicht gesendet werden bei Zahlungsfehler
      expect(emailService.sendOrderConfirmation).not.toHaveBeenCalled();
    });

    it('should validate order items before processing', async () => {
      // Arrange
      const user: User = { id: '1', email: 'customer@example.com' } as User;
      
      // Leere Bestellliste
      const emptyOrderItems = [];

      // Act & Assert
      await expect(service.createOrder(user, emptyOrderItems)).rejects.toThrow(BadRequestException);
      expect(orderRepository.create).not.toHaveBeenCalled();
      expect(paymentService.processPayment).not.toHaveBeenCalled();
    });

    it('should calculate total correctly with discounts', async () => {
      // Arrange
      const user: User = {
        id: '1',
        email: 'premium@example.com',
        membershipType: 'premium', // Premium-Kunde bekommt 10% Rabatt
      } as User;

      const orderItems = [
        { productId: '1', quantity: 2, price: 50.00 },
      ];

      const subtotal = 100.00;
      const discountAmount = 10.00; // 10% Rabatt
      const expectedTotal = 90.00;

      const mockOrder = {
        id: 'order-1',
        user,
        items: orderItems,
        subtotal,
        discountAmount,
        total: expectedTotal,
        status: 'confirmed',
      } as Order;

      orderRepository.create.mockReturnValue(mockOrder);
      orderRepository.save.mockResolvedValue(mockOrder);
      paymentService.processPayment.mockResolvedValue({
        success: true,
        transactionId: 'txn-123',
      });

      // Act
      const result = await service.createOrder(user, orderItems);

      // Assert
      expect(result.total).toBe(expectedTotal);
      expect(result.discountAmount).toBe(discountAmount);
      expect(paymentService.processPayment).toHaveBeenCalledWith(
        expect.objectContaining({ amount: expectedTotal })
      );
    });
  });

  describe('cancelOrder', () => {
    it('should cancel order and process refund', async () => {
      // Arrange
      const orderId = 'order-1';
      const mockOrder = {
        id: orderId,
        status: 'confirmed',
        total: 99.99,
        paymentTransactionId: 'txn-123',
        user: { email: 'customer@example.com' },
      } as Order;

      orderRepository.findOne.mockResolvedValue(mockOrder);
      orderRepository.save.mockResolvedValue({ ...mockOrder, status: 'cancelled' });
      paymentService.refundPayment.mockResolvedValue({ success: true });
      emailService.sendOrderCancellation.mockResolvedValue(undefined);

      // Act
      const result = await service.cancelOrder(orderId);

      // Assert
      expect(result.status).toBe('cancelled');
      expect(paymentService.refundPayment).toHaveBeenCalledWith('txn-123', 99.99);
      expect(emailService.sendOrderCancellation).toHaveBeenCalledWith(
        'customer@example.com',
        mockOrder
      );
    });

    it('should not allow cancellation of already shipped orders', async () => {
      // Arrange
      const orderId = 'order-1';
      const mockOrder = {
        id: orderId,
        status: 'shipped', // Bereits versandt
        total: 99.99,
      } as Order;

      orderRepository.findOne.mockResolvedValue(mockOrder);

      // Act & Assert
      await expect(service.cancelOrder(orderId)).rejects.toThrow(BadRequestException);
      expect(paymentService.refundPayment).not.toHaveBeenCalled();
    });
  });
});

16.5.2 Mocking von Dependencies

Das effektive Mocken von Dependencies ist entscheidend für isolierte Unit-Tests.

// Verschiedene Mocking-Strategien demonstrieren
describe('OrdersService with Complex Dependencies', () => {
  let service: OrdersService;
  
  // Verwende jest.createMockFromModule für automatische Mocks
  const mockInventoryService = jest.createMockFromModule<InventoryService>('../inventory/inventory.service');
  const mockNotificationService = jest.createMockFromModule<NotificationService>('../notification/notification.service');

  beforeEach(async () => {
    // Realistischere Mock-Implementierungen
    const mockRepositoryFactory = () => ({
      create: jest.fn((entity) => ({ id: 'generated-id', ...entity })),
      save: jest.fn((entity) => Promise.resolve(entity)),
      find: jest.fn(() => Promise.resolve([])),
      findOne: jest.fn(),
      createQueryBuilder: jest.fn(() => ({
        where: jest.fn().mockReturnThis(),
        andWhere: jest.fn().mockReturnThis(),
        orderBy: jest.fn().mockReturnThis(),
        getMany: jest.fn(() => Promise.resolve([])),
        getOne: jest.fn(() => Promise.resolve(null)),
      })),
    });

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        OrdersService,
        {
          provide: getRepositoryToken(Order),
          useFactory: mockRepositoryFactory,
        },
        {
          provide: InventoryService,
          useValue: {
            checkAvailability: jest.fn(),
            reserveItems: jest.fn(),
            releaseReservation: jest.fn(),
          },
        },
        {
          provide: NotificationService,
          useValue: {
            sendOrderNotification: jest.fn(),
            sendInventoryAlert: jest.fn(),
          },
        },
      ],
    }).compile();

    service = module.get<OrdersService>(OrdersService);
  });

  describe('createOrderWithInventoryCheck', () => {
    it('should check inventory before creating order', async () => {
      // Arrange
      const orderItems = [
        { productId: '1', quantity: 5 },
        { productId: '2', quantity: 2 },
      ];

      const inventoryService = service['inventoryService']; // Private property access für Test
      
      // Mock verschiedene Szenarien
      inventoryService.checkAvailability
        .mockResolvedValueOnce({ available: true, stock: 10 }) // Produkt 1
        .mockResolvedValueOnce({ available: true, stock: 3 });  // Produkt 2

      inventoryService.reserveItems.mockResolvedValue({ success: true });

      // Act
      await service.createOrderWithInventoryCheck(orderItems);

      // Assert
      expect(inventoryService.checkAvailability).toHaveBeenCalledTimes(2);
      expect(inventoryService.checkAvailability).toHaveBeenNthCalledWith(1, '1', 5);
      expect(inventoryService.checkAvailability).toHaveBeenNthCalledWith(2, '2', 2);
      expect(inventoryService.reserveItems).toHaveBeenCalledWith(orderItems);
    });

    it('should handle inventory shortage gracefully', async () => {
      // Arrange
      const orderItems = [{ productId: '1', quantity: 10 }];
      
      const inventoryService = service['inventoryService'];
      inventoryService.checkAvailability.mockResolvedValue({
        available: false,
        stock: 5,
        message: 'Insufficient stock',
      });

      // Act & Assert
      await expect(service.createOrderWithInventoryCheck(orderItems))
        .rejects.toThrow('Insufficient stock');

      expect(inventoryService.reserveItems).not.toHaveBeenCalled();
    });
  });

  describe('Async operation testing', () => {
    it('should handle concurrent order creation', async () => {
      // Teste, wie der Service mit gleichzeitigen Anfragen umgeht
      const orderPromises = Array.from({ length: 5 }, (_, i) => 
        service.createOrder({ id: `user-${i}` } as User, [
          { productId: '1', quantity: 1, price: 10 }
        ])
      );

      const results = await Promise.allSettled(orderPromises);
      
      // Alle Bestellungen sollten erfolgreich sein
      results.forEach(result => {
        expect(result.status).toBe('fulfilled');
      });
    });

    it('should timeout long-running operations', async () => {
      // Simuliere einen langsamen externen Service
      const inventoryService = service['inventoryService'];
      inventoryService.checkAvailability.mockImplementation(
        () => new Promise(resolve => setTimeout(resolve, 10000)) // 10 Sekunden
      );

      // Der Service sollte nach einem Timeout abbrechen
      await expect(
        service.createOrderWithInventoryCheck([{ productId: '1', quantity: 1 }])
      ).rejects.toThrow('Operation timeout');
    });
  });
});

16.5.3 Database-Mocking

Für realistische Tests benötigen wir oft auch Database-Mocking-Strategien.

import { Test, TestingModule } from '@nestjs/testing';
import { TypeOrmModule, getRepositoryToken } from '@nestjs/typeorm';
import { Repository, Connection } from 'typeorm';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';

describe('UsersService with Database Mocking', () => {
  let service: UsersService;
  let repository: Repository<User>;
  let connection: Connection;

  describe('With Mock Repository', () => {
    beforeEach(async () => {
      // Erstelle einen detaillierten Repository-Mock
      const mockRepository = {
        find: jest.fn(),
        findOne: jest.fn(),
        save: jest.fn(),
        create: jest.fn(),
        delete: jest.fn(),
        createQueryBuilder: jest.fn(() => ({
          where: jest.fn().mockReturnThis(),
          andWhere: jest.fn().mockReturnThis(),
          orderBy: jest.fn().mockReturnThis(),
          skip: jest.fn().mockReturnThis(),
          take: jest.fn().mockReturnThis(),
          getMany: jest.fn(),
          getOne: jest.fn(),
          getManyAndCount: jest.fn(),
        })),
        manager: {
          transaction: jest.fn((callback) => callback({
            save: jest.fn(),
            create: jest.fn(),
            findOne: jest.fn(),
          })),
        },
      };

      const module: TestingModule = await Test.createTestingModule({
        providers: [
          UsersService,
          {
            provide: getRepositoryToken(User),
            useValue: mockRepository,
          },
        ],
      }).compile();

      service = module.get<UsersService>(UsersService);
      repository = module.get<Repository<User>>(getRepositoryToken(User));
    });

    it('should handle complex queries with mocked query builder', async () => {
      // Arrange
      const mockUsers = [
        { id: '1', email: 'user1@test.com', isActive: true },
        { id: '2', email: 'user2@test.com', isActive: true },
      ];

      const queryBuilder = repository.createQueryBuilder();
      (queryBuilder.getMany as jest.Mock).mockResolvedValue(mockUsers);
      (queryBuilder.getManyAndCount as jest.Mock).mockResolvedValue([mockUsers, 2]);

      // Act
      const result = await service.findActiveUsersWithPagination(1, 10);

      // Assert
      expect(result.users).toEqual(mockUsers);
      expect(result.total).toBe(2);
      expect(queryBuilder.where).toHaveBeenCalledWith('user.isActive = :isActive', { isActive: true });
    });

    it('should handle database transactions in mocked environment', async () => {
      // Arrange
      const userData = {
        email: 'test@example.com',
        firstName: 'Test',
        lastName: 'User',
      };

      const profileData = {
        bio: 'Test bio',
        avatar: 'avatar.jpg',
      };

      // Mock transaction behavior
      (repository.manager.transaction as jest.Mock).mockImplementation(
        async (callback) => {
          const manager = {
            save: jest.fn().mockResolvedValue({ id: '1', ...userData }),
            create: jest.fn().mockReturnValue(userData),
          };
          return callback(manager);
        }
      );

      // Act
      const result = await service.createUserWithProfile(userData, profileData);

      // Assert
      expect(repository.manager.transaction).toHaveBeenCalled();
      expect(result).toBeDefined();
    });
  });

  describe('With In-Memory Database', () => {
    beforeEach(async () => {
      const module: TestingModule = await Test.createTestingModule({
        imports: [
          TypeOrmModule.forRoot({
            type: 'sqlite',
            database: ':memory:',
            entities: [User],
            synchronize: true,
            logging: false,
          }),
          TypeOrmModule.forFeature([User]),
        ],
        providers: [UsersService],
      }).compile();

      service = module.get<UsersService>(UsersService);
      repository = module.get<Repository<User>>(getRepositoryToken(User));
      connection = module.get<Connection>(Connection);
    });

    afterEach(async () => {
      await connection.close();
    });

    beforeEach(async () => {
      // Räume die Datenbank vor jedem Test auf
      await repository.clear();
    });

    it('should perform real database operations', async () => {
      // Arrange
      const userData = {
        email: 'real@example.com',
        firstName: 'Real',
        lastName: 'User',
        password: 'hashedPassword',
      };

      // Act
      const createdUser = await service.create(userData);
      const foundUser = await service.findById(createdUser.id);

      // Assert
      expect(foundUser).toBeDefined();
      expect(foundUser.email).toBe(userData.email);
      expect(foundUser.id).toBe(createdUser.id);
    });

    it('should handle database constraints', async () => {
      // Arrange
      const userData = {
        email: 'duplicate@example.com',
        firstName: 'First',
        lastName: 'User',
        password: 'password',
      };

      // Act - Erstelle ersten User
      await service.create(userData);

      // Assert - Versuche zweiten User mit gleicher E-Mail zu erstellen
      await expect(service.create(userData)).rejects.toThrow();
    });
  });
});

16.6 E2E-Testing mit Jest und Supertest

End-to-End-Tests mit Jest und Supertest bilden das Rückgrat unserer API-Testing-Strategie. Sie simulieren echte Benutzerinteraktionen und geben uns Vertrauen in die gesamte Anwendung.

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';

describe('Complete E2E Application Flow', () => {
  let app: INestApplication;
  let authToken: string;
  let userId: string;

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [
        ConfigModule.forRoot({
          envFilePath: '.env.test', // Spezielle Test-Umgebungsvariablen
        }),
        AppModule,
      ],
    })
    // Überschreibe die Produktions-Datenbank für Tests
    .overrideModule(TypeOrmModule)
    .useModule(
      TypeOrmModule.forRoot({
        type: 'sqlite',
        database: ':memory:',
        autoLoadEntities: true,
        synchronize: true,
        logging: false,
      }),
    )
    .compile();

    app = moduleFixture.createNestApplication();

    // Konfiguriere die Anwendung genau wie in Production
    app.useGlobalPipes(
      new ValidationPipe({
        whitelist: true,
        forbidNonWhitelisted: true,
        transform: true,
      }),
    );

    // Weitere Middleware wie in der echten Anwendung
    app.enableCors();
    
    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  describe('User Registration and Authentication Flow', () => {
    const testUser = {
      email: 'e2e.test@example.com',
      firstName: 'E2E',
      lastName: 'Test',
      password: 'SecurePassword123!',
    };

    it('should complete full user registration flow', async () => {
      // Schritt 1: User-Registrierung
      const registerResponse = await request(app.getHttpServer())
        .post('/auth/register')
        .send(testUser)
        .expect(201);

      expect(registerResponse.body).toMatchObject({
        email: testUser.email,
        firstName: testUser.firstName,
        lastName: testUser.lastName,
      });

      // Überprüfe, dass sensible Daten nicht zurückgegeben werden
      expect(registerResponse.body).not.toHaveProperty('password');
      
      userId = registerResponse.body.id;

      // Schritt 2: Login mit neuen Credentials
      const loginResponse = await request(app.getHttpServer())
        .post('/auth/login')
        .send({
          email: testUser.email,
          password: testUser.password,
        })
        .expect(200);

      expect(loginResponse.body).toHaveProperty('accessToken');
      expect(loginResponse.body).toHaveProperty('refreshToken');
      expect(loginResponse.body).toHaveProperty('expiresIn');

      authToken = loginResponse.body.accessToken;

      // Schritt 3: Zugriff auf geschützte Route
      const profileResponse = await request(app.getHttpServer())
        .get('/auth/profile')
        .set('Authorization', `Bearer ${authToken}`)
        .expect(200);

      expect(profileResponse.body.email).toBe(testUser.email);
    });

    it('should reject duplicate email registration', async () => {
      // Versuche, den gleichen User nochmals zu registrieren
      await request(app.getHttpServer())
        .post('/auth/register')
        .send(testUser)
        .expect(409); // Conflict
    });

    it('should reject invalid login credentials', async () => {
      await request(app.getHttpServer())
        .post('/auth/login')
        .send({
          email: testUser.email,
          password: 'WrongPassword',
        })
        .expect(401);
    });
  });

  describe('Protected Resource Access', () => {
    beforeEach(() => {
      // Stelle sicher, dass wir für jeden Test authentifiziert sind
      if (!authToken) {
        throw new Error('Authentication required for this test suite');
      }
    });

    it('should allow access to protected resources with valid token', async () => {
      const response = await request(app.getHttpServer())
        .get('/users/profile')
        .set('Authorization', `Bearer ${authToken}`)
        .expect(200);

      expect(response.body).toHaveProperty('email');
      expect(response.body).toHaveProperty('firstName');
    });

    it('should reject access without token', async () => {
      await request(app.getHttpServer())
        .get('/users/profile')
        .expect(401);
    });

    it('should reject access with invalid token', async () => {
      await request(app.getHttpServer())
        .get('/users/profile')
        .set('Authorization', 'Bearer invalid.token.here')
        .expect(401);
    });

    it('should allow profile updates', async () => {
      const updateData = {
        firstName: 'Updated',
        lastName: 'Name',
      };

      const response = await request(app.getHttpServer())
        .patch('/users/profile')
        .set('Authorization', `Bearer ${authToken}`)
        .send(updateData)
        .expect(200);

      expect(response.body.firstName).toBe('Updated');
      expect(response.body.lastName).toBe('Name');

      // Verifiziere, dass die Änderungen persistent sind
      const profileResponse = await request(app.getHttpServer())
        .get('/users/profile')
        .set('Authorization', `Bearer ${authToken}`)
        .expect(200);

      expect(profileResponse.body.firstName).toBe('Updated');
    });
  });

  describe('Data Validation and Error Handling', () => {
    it('should validate request data properly', async () => {
      const invalidData = {
        email: 'not-an-email',
        firstName: '', // Empty string
        lastName: 'ValidLastName',
        password: '123', // Too short
      };

      const response = await request(app.getHttpServer())
        .post('/auth/register')
        .send(invalidData)
        .expect(400);

      expect(response.body).toHaveProperty('message');
      expect(Array.isArray(response.body.message)).toBe(true);
      
      // Überprüfe, dass spezifische Validierungsfehler enthalten sind
      const messages = response.body.message.join(' ');
      expect(messages).toContain('email');
      expect(messages).toContain('firstName');
      expect(messages).toContain('password');
    });

    it('should handle unexpected server errors gracefully', async () => {
      // Simuliere einen Server-Fehler durch ungültige Daten
      const response = await request(app.getHttpServer())
        .get('/users/invalid-endpoint')
        .set('Authorization', `Bearer ${authToken}`)
        .expect(404);

      expect(response.body).toHaveProperty('message');
      expect(response.body).toHaveProperty('statusCode', 404);
    });
  });

  describe('Rate Limiting and Security', () => {
    it('should apply rate limiting to authentication endpoints', async () => {
      const loginData = {
        email: 'test@example.com',
        password: 'wrongpassword',
      };

      // Führe viele fehlgeschlagene Login-Versuche durch
      const promises = Array.from({ length: 10 }, () =>
        request(app.getHttpServer())
          .post('/auth/login')
          .send(loginData)
      );

      const responses = await Promise.all(promises);

      // Mindestens einer der Requests sollte rate-limited sein
      const rateLimitedResponses = responses.filter(res => res.status === 429);
      expect(rateLimitedResponses.length).toBeGreaterThan(0);
    });

    it('should set appropriate security headers', async () => {
      const response = await request(app.getHttpServer())
        .get('/health')
        .expect(200);

      // Überprüfe wichtige Security-Headers
      expect(response.headers).toHaveProperty('x-content-type-options');
      expect(response.headers).toHaveProperty('x-frame-options');
    });
  });

  describe('API Performance and Reliability', () => {
    it('should respond within acceptable time limits', async () => {
      const startTime = Date.now();

      await request(app.getHttpServer())
        .get('/health')
        .expect(200);

      const responseTime = Date.now() - startTime;
      
      // API sollte innerhalb von 1 Sekunde antworten
      expect(responseTime).toBeLessThan(1000);
    });

    it('should handle concurrent requests properly', async () => {
      // Führe mehrere gleichzeitige Requests aus
      const concurrentRequests = Array.from({ length: 5 }, () =>
        request(app.getHttpServer())
          .get('/users/profile')
          .set('Authorization', `Bearer ${authToken}`)
      );

      const responses = await Promise.all(concurrentRequests);

      // Alle Requests sollten erfolgreich sein
      responses.forEach(response => {
        expect(response.status).toBe(200);
        expect(response.body).toHaveProperty('email');
      });
    });
  });
});

16.7 E2E-Testing mit Cypress

Cypress bietet uns eine mächtige Alternative für End-to-End-Tests, besonders für Frontend-Integration und vollständige User-Journey-Tests.

// cypress/integration/user-management.spec.ts
describe('User Management E2E', () => {
  beforeEach(() => {
    // Setze die Test-Datenbank zurück
    cy.task('db:seed');
    
    // Besuche die Anwendung
    cy.visit('/');
  });

  describe('User Registration Flow', () => {
    it('should register a new user successfully', () => {
      // Navigate to registration page
      cy.get('[data-cy=register-link]').click();
      
      // Fill out registration form
      cy.get('[data-cy=email-input]').type('cypress@test.com');
      cy.get('[data-cy=firstname-input]').type('Cypress');
      cy.get('[data-cy=lastname-input]').type('Test');
      cy.get('[data-cy=password-input]').type('SecurePassword123!');
      cy.get('[data-cy=confirm-password-input]').type('SecurePassword123!');
      
      // Submit form
      cy.get('[data-cy=register-button]').click();
      
      // Verify successful registration
      cy.url().should('include', '/dashboard');
      cy.get('[data-cy=welcome-message]').should('contain', 'Welcome, Cypress');
      
      // Verify API call was made
      cy.get('@registerRequest').should('have.property', 'response.statusCode', 201);
    });

    it('should show validation errors for invalid input', () => {
      cy.get('[data-cy=register-link]').click();
      
      // Submit form without filling fields
      cy.get('[data-cy=register-button]').click();
      
      // Check validation messages
      cy.get('[data-cy=email-error]').should('be.visible');
      cy.get('[data-cy=firstname-error]').should('be.visible');
      cy.get('[data-cy=password-error]').should('be.visible');
    });

    it('should handle server errors gracefully', () => {
      // Mock server error
      cy.intercept('POST', '/api/auth/register', {
        statusCode: 500,
        body: { message: 'Internal server error' }
      }).as('registerError');
      
      cy.get('[data-cy=register-link]').click();
      
      // Fill valid data
      cy.get('[data-cy=email-input]').type('test@error.com');
      cy.get('[data-cy=firstname-input]').type('Test');
      cy.get('[data-cy=lastname-input]').type('User');
      cy.get('[data-cy=password-input]').type('Password123!');
      cy.get('[data-cy=confirm-password-input]').type('Password123!');
      
      cy.get('[data-cy=register-button]').click();
      
      // Verify error handling
      cy.get('[data-cy=error-message]').should('contain', 'Internal server error');
      cy.url().should('include', '/register'); // Should stay on registration page
    });
  });

  describe('Authentication Flow', () => {
    it('should login and access protected pages', () => {
      // Use custom command for login
      cy.login('existing@user.com', 'password123');
      
      // Verify we're logged in
      cy.url().should('include', '/dashboard');
      cy.get('[data-cy=user-menu]').should('be.visible');
      
      // Access protected page
      cy.get('[data-cy=profile-link]').click();
      cy.url().should('include', '/profile');
      
      // Verify profile data is loaded
      cy.get('[data-cy=profile-email]').should('contain', 'existing@user.com');
    });

    it('should logout successfully', () => {
      cy.login('existing@user.com', 'password123');
      
      // Logout
      cy.get('[data-cy=user-menu]').click();
      cy.get('[data-cy=logout-button]').click();
      
      // Verify logout
      cy.url().should('include', '/login');
      cy.get('[data-cy=user-menu]').should('not.exist');
      
      // Try to access protected page
      cy.visit('/profile');
      cy.url().should('include', '/login'); // Should redirect to login
    });

    it('should handle session expiration', () => {
      cy.login('existing@user.com', 'password123');
      
      // Mock expired token
      cy.window().then(win => {
        win.localStorage.setItem('token', 'expired.token.here');
      });
      
      // Try to access protected endpoint
      cy.visit('/profile');
      
      // Should redirect to login due to expired token
      cy.url().should('include', '/login');
      cy.get('[data-cy=session-expired-message]').should('be.visible');
    });
  });

  describe('Profile Management', () => {
    beforeEach(() => {
      cy.login('existing@user.com', 'password123');
      cy.visit('/profile');
    });

    it('should update profile information', () => {
      // Update first name
      cy.get('[data-cy=firstname-input]').clear().type('Updated');
      cy.get('[data-cy=lastname-input]').clear().type('User');
      
      cy.get('[data-cy=save-profile-button]').click();
      
      // Verify success message
      cy.get('[data-cy=success-message]').should('contain', 'Profile updated successfully');
      
      // Verify changes are saved
      cy.reload();
      cy.get('[data-cy=firstname-input]').should('have.value', 'Updated');
      cy.get('[data-cy=lastname-input]').should('have.value', 'User');
    });

    it('should change password', () => {
      cy.get('[data-cy=change-password-tab]').click();
      
      cy.get('[data-cy=current-password-input]').type('password123');
      cy.get('[data-cy=new-password-input]').type('NewPassword123!');
      cy.get('[data-cy=confirm-new-password-input]').type('NewPassword123!');
      
      cy.get('[data-cy=change-password-button]').click();
      
      // Verify success
      cy.get('[data-cy=password-changed-message]').should('be.visible');
      
      // Test login with new password
      cy.logout();
      cy.login('existing@user.com', 'NewPassword123!');
      cy.url().should('include', '/dashboard');
    });

    it('should upload profile picture', () => {
      // Mock file upload
      const fileName = 'profile-picture.jpg';
      cy.fixture(fileName).then(fileContent => {
        cy.get('[data-cy=profile-picture-input]').selectFile({
          contents: Cypress.Buffer.from(fileContent),
          fileName,
          mimeType: 'image/jpeg',
        });
      });
      
      cy.get('[data-cy=upload-picture-button]').click();
      
      // Verify upload success
      cy.get('[data-cy=profile-picture]')
        .should('have.attr', 'src')
        .and('include', fileName);
    });
  });
});

// cypress/support/commands.ts - Custom Commands
declare global {
  namespace Cypress {
    interface Chainable {
      login(email: string, password: string): Chainable<void>;
      logout(): Chainable<void>;
    }
  }
}

Cypress.Commands.add('login', (email: string, password: string) => {
  cy.request({
    method: 'POST',
    url: '/api/auth/login',
    body: {
      email,
      password,
    },
  }).then((response) => {
    window.localStorage.setItem('token', response.body.accessToken);
    window.localStorage.setItem('refreshToken', response.body.refreshToken);
  });
});

Cypress.Commands.add('logout', () => {
  window.localStorage.removeItem('token');
  window.localStorage.removeItem('refreshToken');
  cy.visit('/login');
});

// cypress/plugins/index.ts - Database Seeding
module.exports = (on: any, config: any) => {
  on('task', {
    'db:seed': () => {
      // Reset and seed test database
      return require('../helpers/db-helper').seedDatabase();
    },
  });
};

16.8 E2E-Testing mit Playwright

Playwright bietet moderne Browser-Automatisierung und ist besonders gut für komplexe User-Interactions.

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

test.describe('Complete User Journey', () => {
  let page: Page;

  test.beforeEach(async ({ browser }) => {
    // Erstelle neuen Browser-Kontext für jeden Test
    const context = await browser.newContext({
      // Simuliere verschiedene Geräte/Browser
      viewport: { width: 1280, height: 720 },
      userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
    });
    
    page = await context.newPage();
    
    // Setze up API-Mocking
    await page.route('/api/**', async route => {
      const url = route.request().url();
      if (url.includes('/auth/login')) {
        await route.fulfill({
          status: 200,
          contentType: 'application/json',
          body: JSON.stringify({
            accessToken: 'mock.jwt.token',
            refreshToken: 'mock.refresh.token',
            user: { id: '1', email: 'test@example.com' }
          })
        });
      } else {
        await route.continue();
      }
    });

    await page.goto('http://localhost:3000');
  });

  test('should complete user registration and login flow', async () => {
    // Registration
    await page.click('text=Register');
    await page.fill('[data-testid=email-input]', 'playwright@test.com');
    await page.fill('[data-testid=firstname-input]', 'Playwright');
    await page.fill('[data-testid=lastname-input]', 'Test');
    await page.fill('[data-testid=password-input]', 'SecurePassword123!');
    await page.fill('[data-testid=confirm-password-input]', 'SecurePassword123!');
    
    // Submit registration
    await page.click('[data-testid=register-button]');
    
    // Wait for navigation to dashboard
    await page.waitForURL('**/dashboard');
    await expect(page.locator('[data-testid=welcome-message]')).toContainText('Welcome');
  });

  test('should handle form validation errors', async () => {
    await page.click('text=Register');
    
    // Submit empty form
    await page.click('[data-testid=register-button]');
    
    // Check validation errors appear
    await expect(page.locator('[data-testid=email-error]')).toBeVisible();
    await expect(page.locator('[data-testid=password-error]')).toBeVisible();
    
    // Fill valid email, invalid password
    await page.fill('[data-testid=email-input]', 'valid@email.com');
    await page.fill('[data-testid=password-input]', '123'); // Too short
    
    await page.click('[data-testid=register-button]');
    
    // Email error should be gone, password error remains
    await expect(page.locator('[data-testid=email-error]')).toBeHidden();
    await expect(page.locator('[data-testid=password-error]')).toBeVisible();
  });

  test('should work on mobile devices', async ({ browser }) => {
    // Test mobile viewport
    const mobileContext = await browser.newContext({
      viewport: { width: 375, height: 667 }, // iPhone SE
      userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_7_1 like Mac OS X)',
    });
    
    const mobilePage = await mobileContext.newPage();
    await mobilePage.goto('http://localhost:3000');
    
    // Mobile navigation should be collapsed
    await expect(mobilePage.locator('[data-testid=mobile-menu-button]')).toBeVisible();
    await expect(mobilePage.locator('[data-testid=desktop-navigation]')).toBeHidden();
    
    // Test mobile menu functionality
    await mobilePage.click('[data-testid=mobile-menu-button]');
    await expect(mobilePage.locator('[data-testid=mobile-menu]')).toBeVisible();
    
    await mobileContext.close();
  });

  test('should handle slow network conditions', async ({ browser }) => {
    // Simuliere langsame Netzwerkverbindung
    const context = await browser.newContext();
    const cdpSession = await context.newCDPSession(page);
    
    await cdpSession.send('Network.emulateNetworkConditions', {
      offline: false,
      downloadThroughput: 100 * 1024, // 100kb/s
      uploadThroughput: 100 * 1024,
      latency: 2000, // 2s latency
    });
    
    const slowPage = await context.newPage();
    await slowPage.goto('http://localhost:3000');
    
    // App sollte Loading-States anzeigen
    await expect(slowPage.locator('[data-testid=loading-spinner]')).toBeVisible();
    
    // Warte auf vollständiges Laden
    await slowPage.waitForLoadState('networkidle');
    await expect(slowPage.locator('[data-testid=loading-spinner]')).toBeHidden();
    
    await context.close();
  });

  test('should be accessible', async () => {
    // Teste Accessibility
    await page.click('text=Register');
    
    // Prüfe, dass Form-Labels korrekt mit Inputs verknüpft sind
    const emailInput = page.locator('[data-testid=email-input]');
    await expect(emailInput).toHaveAttribute('aria-label');
    
    // Teste Keyboard-Navigation
    await page.keyboard.press('Tab'); // Navigate to first field
    await page.keyboard.type('test@example.com');
    
    await page.keyboard.press('Tab'); // Navigate to next field
    await page.keyboard.type('Test');
    
    // Teste, dass Fehler-Messages screen reader zugänglich sind
    await page.keyboard.press('Tab'); // Skip to submit button
    await page.keyboard.press('Tab');
    await page.keyboard.press('Enter'); // Submit incomplete form
    
    const errorMessage = page.locator('[data-testid=password-error]');
    await expect(errorMessage).toHaveAttribute('role', 'alert');
  });

  test('should handle API errors gracefully', async () => {
    // Mock API error
    await page.route('/api/auth/register', route => 
      route.fulfill({
        status: 500,
        contentType: 'application/json',
        body: JSON.stringify({ message: 'Internal server error' })
      })
    );

    await page.click('text=Register');
    
    // Fill form with valid data
    await page.fill('[data-testid=email-input]', 'test@example.com');
    await page.fill('[data-testid=firstname-input]', 'Test');
    await page.fill('[data-testid=lastname-input]', 'User');
    await page.fill('[data-testid=password-input]', 'SecurePassword123!');
    await page.fill('[data-testid=confirm-password-input]', 'SecurePassword123!');
    
    await page.click('[data-testid=register-button]');
    
    // Verify error message is displayed
    await expect(page.locator('[data-testid=error-message]')).toContainText('server error');
    
    // Verify user stays on registration page
    await expect(page).toHaveURL(/.*register/);
  });

  test('should support offline functionality', async ({ browser }) => {
    const context = await browser.newContext();
    const offlinePage = await context.newPage();
    
    // Go online first, load the app
    await offlinePage.goto('http://localhost:3000');
    await offlinePage.waitForLoadState('networkidle');
    
    // Go offline
    await context.setOffline(true);
    
    // Test offline behavior
    await offlinePage.reload();
    await expect(offlinePage.locator('[data-testid=offline-banner]')).toBeVisible();
    
    // Try to submit form while offline
    await offlinePage.click('text=Register');
    await offlinePage.fill('[data-testid=email-input]', 'offline@test.com');
    await offlinePage.click('[data-testid=register-button]');
    
    // Should show offline message
    await expect(offlinePage.locator('[data-testid=offline-message]')).toBeVisible();
    
    await context.close();
  });
});

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

export default defineConfig({
  testDir: './tests/e2e',
  
  // Timeout für Tests
  timeout: 30 * 1000,
  
  // Erwarte, dass Tests nicht flaky sind
  expect: {
    timeout: 5000,
  },
  
  // Retry-Strategie
  retries: process.env.CI ? 2 : 0,
  
  // Parallel execution
  workers: process.env.CI ? 1 : undefined,
  
  // Reporter-Konfiguration
  reporter: [
    ['html'],
    ['junit', { outputFile: 'test-results/junit.xml' }],
  ],
  
  use: {
    // Base URL für Tests
    baseURL: 'http://localhost:3000',
    
    // Screenshots bei Fehlern
    screenshot: 'only-on-failure',
    
    // Videos bei Fehlern
    video: 'retain-on-failure',
    
    // Trace-Aufzeichnung
    trace: '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'] },
    },
  ],

  // Development Server
  webServer: {
    command: 'npm run start:test',
    port: 3000,
    reuseExistingServer: !process.env.CI,
  },
});

16.9 Database Testing Strategies

Datenbankintegration in Tests erfordert besondere Aufmerksamkeit, da wir sowohl Performance als auch Isolation gewährleisten müssen.

// test/helpers/database-helper.ts
import { DataSource } from 'typeorm';
import { User } from '../../src/users/entities/user.entity';
import { Post } from '../../src/posts/entities/post.entity';

export class DatabaseTestHelper {
  private static dataSource: DataSource;

  static async setupTestDatabase(): Promise<DataSource> {
    if (!this.dataSource) {
      this.dataSource = new DataSource({
        type: 'sqlite',
        database: ':memory:', // In-Memory für schnelle Tests
        entities: [User, Post],
        synchronize: true,
        logging: false,
        dropSchema: true,
      });

      await this.dataSource.initialize();
    }

    return this.dataSource;
  }

  static async cleanDatabase(): Promise<void> {
    if (!this.dataSource?.isInitialized) return;

    // Lösche alle Daten in umgekehrter Reihenfolge der Dependencies
    const entities = this.dataSource.entityMetadatas;
    
    for (const entity of entities.reverse()) {
      const repository = this.dataSource.getRepository(entity.name);
      await repository.clear();
    }
  }

  static async seedTestData(): Promise<{
    users: User[];
    posts: Post[];
  }> {
    const userRepository = this.dataSource.getRepository(User);
    const postRepository = this.dataSource.getRepository(Post);

    // Erstelle Test-User
    const users = await userRepository.save([
      {
        email: 'admin@test.com',
        firstName: 'Admin',
        lastName: 'User',
        password: 'hashedPassword',
        role: 'admin',
      },
      {
        email: 'user@test.com',
        firstName: 'Regular',
        lastName: 'User',
        password: 'hashedPassword',
        role: 'user',
      },
    ]);

    // Erstelle Test-Posts
    const posts = await postRepository.save([
      {
        title: 'First Test Post',
        content: 'This is the content of the first test post',
        author: users[0],
        published: true,
      },
      {
        title: 'Second Test Post',
        content: 'This is the content of the second test post',
        author: users[1],
        published: false,
      },
    ]);

    return { users, posts };
  }

  static async closeDatabase(): Promise<void> {
    if (this.dataSource?.isInitialized) {
      await this.dataSource.destroy();
    }
  }
}

// Integration Tests with Database
describe('Posts Service Database Integration', () => {
  let dataSource: DataSource;
  let postsService: PostsService;
  let testData: { users: User[]; posts: Post[] };

  beforeAll(async () => {
    dataSource = await DatabaseTestHelper.setupTestDatabase();
    
    const module: TestingModule = await Test.createTestingModule({
      imports: [
        TypeOrmModule.forRoot({
          type: 'sqlite',
          database: ':memory:',
          entities: [User, Post],
          synchronize: true,
        }),
        TypeOrmModule.forFeature([User, Post]),
      ],
      providers: [PostsService],
    }).compile();

    postsService = module.get<PostsService>(PostsService);
  });

  afterAll(async () => {
    await DatabaseTestHelper.closeDatabase();
  });

  beforeEach(async () => {
    await DatabaseTestHelper.cleanDatabase();
    testData = await DatabaseTestHelper.seedTestData();
  });

  describe('findPublishedPosts', () => {
    it('should return only published posts', async () => {
      const publishedPosts = await postsService.findPublishedPosts();
      
      expect(publishedPosts).toHaveLength(1);
      expect(publishedPosts[0].title).toBe('First Test Post');
      expect(publishedPosts[0].published).toBe(true);
    });

    it('should return posts with author information', async () => {
      const publishedPosts = await postsService.findPublishedPosts();
      
      expect(publishedPosts[0].author).toBeDefined();
      expect(publishedPosts[0].author.email).toBe('admin@test.com');
    });
  });

  describe('createPost', () => {
    it('should create post with valid data', async () => {
      const createPostDto = {
        title: 'New Test Post',
        content: 'Content for new test post',
        published: true,
      };

      const newPost = await postsService.create(testData.users[0].id, createPostDto);
      
      expect(newPost).toBeDefined();
      expect(newPost.title).toBe(createPostDto.title);
      expect(newPost.author.id).toBe(testData.users[0].id);

      // Verifiziere, dass der Post in der Datenbank gespeichert wurde
      const savedPost = await postsService.findById(newPost.id);
      expect(savedPost.title).toBe(createPostDto.title);
    });

    it('should handle database constraints correctly', async () => {
      // Versuche, einen Post mit ungültigem Author zu erstellen
      const createPostDto = {
        title: 'Invalid Post',
        content: 'This should fail',
        published: true,
      };

      await expect(
        postsService.create('non-existent-user-id', createPostDto)
      ).rejects.toThrow();
    });
  });

  describe('Complex Queries', () => {
    it('should handle pagination correctly', async () => {
      // Erstelle mehr Test-Daten
      const postRepository = dataSource.getRepository(Post);
      const additionalPosts = Array.from({ length: 15 }, (_, i) => ({
        title: `Post ${i + 3}`,
        content: `Content ${i + 3}`,
        author: testData.users[0],
        published: true,
      }));

      await postRepository.save(additionalPosts);

      // Teste erste Seite
      const page1 = await postsService.findPaginated(1, 5);
      expect(page1.posts).toHaveLength(5);
      expect(page1.total).toBe(16); // 1 original + 15 neue
      expect(page1.currentPage).toBe(1);
      expect(page1.totalPages).toBe(4);

      // Teste zweite Seite
      const page2 = await postsService.findPaginated(2, 5);
      expect(page2.posts).toHaveLength(5);
      expect(page2.currentPage).toBe(2);

      // Überprüfe, dass keine Posts doppelt vorkommen
      const page1Ids = page1.posts.map(p => p.id);
      const page2Ids = page2.posts.map(p => p.id);
      const intersection = page1Ids.filter(id => page2Ids.includes(id));
      expect(intersection).toHaveLength(0);
    });

    it('should search posts by title and content', async () => {
      const searchResults = await postsService.searchPosts('First');
      
      expect(searchResults).toHaveLength(1);
      expect(searchResults[0].title).toContain('First');
    });

    it('should handle concurrent database operations', async () => {
      // Simuliere gleichzeitige Post-Erstellung
      const createPromises = Array.from({ length: 5 }, (_, i) =>
        postsService.create(testData.users[0].id, {
          title: `Concurrent Post ${i}`,
          content: `Content ${i}`,
          published: true,
        })
      );

      const createdPosts = await Promise.all(createPromises);
      
      // Alle Posts sollten erfolgreich erstellt worden sein
      expect(createdPosts).toHaveLength(5);
      
      // Alle sollten unterschiedliche IDs haben
      const ids = createdPosts.map(p => p.id);
      const uniqueIds = [...new Set(ids)];
      expect(uniqueIds).toHaveLength(5);
    });
  });

  describe('Transaction Handling', () => {
    it('should rollback on error in transaction', async () => {
      const initialPostCount = await postsService.count();

      try {
        await dataSource.transaction(async manager => {
          // Erstelle einen Post erfolgreich
          const post = manager.create(Post, {
            title: 'Transaction Test',
            content: 'This should be rolled back',
            author: testData.users[0],
            published: true,
          });
          await manager.save(post);

          // Simuliere einen Fehler
          throw new Error('Simulated transaction error');
        });
      } catch (error) {
        // Fehler ist erwartet
      }

      // Post-Anzahl sollte unverändert sein
      const finalPostCount = await postsService.count();
      expect(finalPostCount).toBe(initialPostCount);
    });
  });
});

16.9.1 Docker-basierte Database Tests

Für realistische Integration Tests können wir auch Docker-Container verwenden:

// test/helpers/docker-database.helper.ts
import { Client } from 'pg';
import { execSync } from 'child_process';

export class DockerDatabaseHelper {
  private static containerId: string;
  private static client: Client;

  static async startPostgresContainer(): Promise<void> {
    // Starte PostgreSQL Container
    const command = `
      docker run -d \
        --name test-postgres-${Date.now()} \
        -e POSTGRES_PASSWORD=testpass \
        -e POSTGRES_USER=testuser \
        -e POSTGRES_DB=testdb \
        -p 0:5432 \
        postgres:13-alpine
    `;

    this.containerId = execSync(command).toString().trim();

    // Warte bis Container bereit ist
    await this.waitForDatabase();
  }

  private static async waitForDatabase(): Promise<void> {
    const port = this.getContainerPort();
    
    this.client = new Client({
      host: 'localhost',
      port,
      user: 'testuser',
      password: 'testpass',
      database: 'testdb',
    });

    let attempts = 0;
    while (attempts < 30) {
      try {
        await this.client.connect();
        console.log('Database ready');
        return;
      } catch (error) {
        attempts++;
        await new Promise(resolve => setTimeout(resolve, 1000));
      }
    }

    throw new Error('Database failed to start within timeout');
  }

  private static getContainerPort(): number {
    const portCommand = `docker port ${this.containerId} 5432`;
    const portOutput = execSync(portCommand).toString().trim();
    return parseInt(portOutput.split(':')[1]);
  }

  static async stopContainer(): Promise<void> {
    if (this.client) {
      await this.client.end();
    }

    if (this.containerId) {
      execSync(`docker stop ${this.containerId}`);
      execSync(`docker rm ${this.containerId}`);
    }
  }

  static getConnectionConfig() {
    return {
      host: 'localhost',
      port: this.getContainerPort(),
      username: 'testuser',
      password: 'testpass',
      database: 'testdb',
    };
  }
}

// Verwendung in Tests
describe('Posts with Real PostgreSQL', () => {
  beforeAll(async () => {
    await DockerDatabaseHelper.startPostgresContainer();
  }, 60000); // Erhöhtes Timeout für Container-Start

  afterAll(async () => {
    await DockerDatabaseHelper.stopContainer();
  });

  // Tests hier...
});

16.10 Testing Guards, Interceptors und Pipes

Guards, Interceptors und Pipes sind wichtige Middleware-Komponenten, die spezielle Testing-Strategien erfordern.

16.10.1 Testing Guards

import { Test, TestingModule } from '@nestjs/testing';
import { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtAuthGuard } from './jwt-auth.guard';
import { RolesGuard } from './roles.guard';

describe('JwtAuthGuard', () => {
  let guard: JwtAuthGuard;
  let reflector: Reflector;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        JwtAuthGuard,
        {
          provide: Reflector,
          useValue: {
            getAllAndOverride: jest.fn(),
          },
        },
      ],
    }).compile();

    guard = module.get<JwtAuthGuard>(JwtAuthGuard);
    reflector = module.get<Reflector>(Reflector);
  });

  const createMockExecutionContext = (
    headers: Record<string, string> = {},
    user?: any
  ): ExecutionContext => {
    const request = {
      headers,
      user,
    };

    return {
      switchToHttp: () => ({
        getRequest: () => request,
        getResponse: () => ({}),
      }),
      getHandler: () => jest.fn(),
      getClass: () => jest.fn(),
    } as any;
  };

  describe('canActivate', () => {
    it('should allow access for public routes', async () => {
      // Simuliere @Public() Decorator
      jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(true);

      const context = createMockExecutionContext();
      const result = await guard.canActivate(context);

      expect(result).toBe(true);
    });

    it('should deny access when no token is provided', async () => {
      jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);

      const context = createMockExecutionContext();

      await expect(guard.canActivate(context)).rejects.toThrow('No token provided');
    });

    it('should allow access with valid token', async () => {
      jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
      
      // Mock JWT verification
      const mockUser = { id: '1', email: 'test@example.com' };
      jest.spyOn(guard as any, 'validateToken').mockResolvedValue(mockUser);

      const context = createMockExecutionContext({
        authorization: 'Bearer valid.jwt.token',
      });

      const result = await guard.canActivate(context);

      expect(result).toBe(true);
    });

    it('should deny access with invalid token', async () => {
      jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
      jest.spyOn(guard as any, 'validateToken').mockRejectedValue(new Error('Invalid token'));

      const context = createMockExecutionContext({
        authorization: 'Bearer invalid.token',
      });

      await expect(guard.canActivate(context)).rejects.toThrow('Invalid token');
    });
  });
});

describe('RolesGuard', () => {
  let guard: RolesGuard;
  let reflector: Reflector;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        RolesGuard,
        {
          provide: Reflector,
          useValue: {
            getAllAndOverride: jest.fn(),
          },
        },
      ],
    }).compile();

    guard = module.get<RolesGuard>(RolesGuard);
    reflector = module.get<Reflector>(Reflector);
  });

  it('should allow access when no roles are required', () => {
    jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined);

    const context = createMockExecutionContext({}, { roles: ['user'] });
    const result = guard.canActivate(context);

    expect(result).toBe(true);
  });

  it('should allow access when user has required role', () => {
    jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['admin']);

    const context = createMockExecutionContext({}, { roles: ['admin', 'user'] });
    const result = guard.canActivate(context);

    expect(result).toBe(true);
  });

  it('should deny access when user lacks required role', () => {
    jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['admin']);

    const context = createMockExecutionContext({}, { roles: ['user'] });

    expect(() => guard.canActivate(context)).toThrow('Insufficient permissions');
  });

  const createMockExecutionContext = (
    headers: Record<string, string> = {},
    user?: any
  ): ExecutionContext => ({
    switchToHttp: () => ({
      getRequest: () => ({ headers, user }),
    }),
    getHandler: () => jest.fn(),
    getClass: () => jest.fn(),
  } as any);
});

16.10.2 Testing Interceptors

import { Test } from '@nestjs/testing';
import { CallHandler, ExecutionContext } from '@nestjs/common';
import { of, throwError } from 'rxjs';
import { LoggingInterceptor } from './logging.interceptor';
import { TransformInterceptor } from './transform.interceptor';

describe('LoggingInterceptor', () => {
  let interceptor: LoggingInterceptor;
  let mockLogger: any;

  beforeEach(async () => {
    mockLogger = {
      log: jest.fn(),
      error: jest.fn(),
    };

    const module = await Test.createTestingModule({
      providers: [
        LoggingInterceptor,
        { provide: 'Logger', useValue: mockLogger },
      ],
    }).compile();

    interceptor = module.get<LoggingInterceptor>(LoggingInterceptor);
  });

  const createMockExecutionContext = (
    method: string = 'GET',
    url: string = '/test'
  ): ExecutionContext => ({
    switchToHttp: () => ({
      getRequest: () => ({ method, url }),
    }),
    getHandler: () => ({ name: 'testHandler' }),
    getClass: () => ({ name: 'TestController' }),
  } as any);

  const createMockCallHandler = (response?: any, shouldThrow?: boolean) => ({
    handle: jest.fn(() => 
      shouldThrow 
        ? throwError(() => new Error('Test error'))
        : of(response || { success: true })
    ),
  });

  it('should log request start and completion', async () => {
    const context = createMockExecutionContext('GET', '/users');
    const callHandler = createMockCallHandler({ users: [] });

    const result = await interceptor
      .intercept(context, callHandler)
      .toPromise();

    expect(mockLogger.log).toHaveBeenCalledWith('GET /users - Start');
    expect(mockLogger.log).toHaveBeenCalledWith(
      expect.stringContaining('GET /users - Success')
    );
    expect(result).toEqual({ users: [] });
  });

  it('should log errors', async () => {
    const context = createMockExecutionContext('POST', '/users');
    const callHandler = createMockCallHandler(null, true);

    try {
      await interceptor
        .intercept(context, callHandler)
        .toPromise();
    } catch (error) {
      // Error is expected
    }

    expect(mockLogger.log).toHaveBeenCalledWith('POST /users - Start');
    expect(mockLogger.error).toHaveBeenCalledWith(
      expect.stringContaining('POST /users - Error')
    );
  });

  it('should measure response time', async () => {
    const context = createMockExecutionContext();
    const callHandler = createMockCallHandler();

    // Mock Date.now to control timing
    const originalDateNow = Date.now;
    Date.now = jest.fn()
      .mockReturnValueOnce(1000) // Start time
      .mockReturnValueOnce(1500); // End time

    await interceptor
      .intercept(context, callHandler)
      .toPromise();

    expect(mockLogger.log).toHaveBeenCalledWith(
      expect.stringContaining('Success (500ms)')
    );

    Date.now = originalDateNow;
  });
});

describe('TransformInterceptor', () => {
  let interceptor: TransformInterceptor;

  beforeEach(() => {
    interceptor = new TransformInterceptor();
  });

  it('should transform response data', async () => {
    const context = {} as ExecutionContext;
    const callHandler = {
      handle: () => of({ id: 1, name: 'Test' }),
    };

    const result = await interceptor
      .intercept(context, callHandler)
      .toPromise();

    expect(result).toEqual({
      success: true,
      data: { id: 1, name: 'Test' },
      timestamp: expect.any(String),
    });
  });

  it('should handle null responses', async () => {
    const context = {} as ExecutionContext;
    const callHandler = {
      handle: () => of(null),
    };

    const result = await interceptor
      .intercept(context, callHandler)
      .toPromise();

    expect(result).toEqual({
      success: true,
      data: null,
      timestamp: expect.any(String),
    });
  });

  it('should not transform error responses', async () => {
    const context = {} as ExecutionContext;
    const callHandler = {
      handle: () => throwError(() => new Error('Test error')),
    };

    await expect(
      interceptor.intercept(context, callHandler).toPromise()
    ).rejects.toThrow('Test error');
  });
});

16.10.3 Testing Pipes

import { Test } from '@nestjs/testing';
import { BadRequestException } from '@nestjs/common';
import { ValidationPipe } from '@nestjs/common';
import { ParseIntPipe } from '@nestjs/common';
import { CustomValidationPipe } from './custom-validation.pipe';
import { CreateUserDto } from './dto/create-user.dto';

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

  beforeEach(() => {
    pipe = new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    });
  });

  it('should validate and transform valid DTO', async () => {
    const validDto = {
      email: 'test@example.com',
      firstName: 'John',
      lastName: 'Doe',
      password: 'SecurePassword123!',
    };

    const metadata = {
      type: 'body',
      metatype: CreateUserDto,
      data: '',
    };

    const result = await pipe.transform(validDto, metadata);

    expect(result).toBeInstanceOf(CreateUserDto);
    expect(result.email).toBe(validDto.email);
  });

  it('should throw error for invalid DTO', async () => {
    const invalidDto = {
      email: 'invalid-email',
      firstName: '', // Empty string
      password: '123', // Too short
    };

    const metadata = {
      type: 'body',
      metatype: CreateUserDto,
      data: '',
    };

    await expect(pipe.transform(invalidDto, metadata))
      .rejects.toThrow(BadRequestException);
  });

  it('should remove non-whitelisted properties', async () => {
    const dtoWithExtra = {
      email: 'test@example.com',
      firstName: 'John',
      lastName: 'Doe',
      password: 'SecurePassword123!',
      maliciousField: 'should be removed',
    };

    const metadata = {
      type: 'body',
      metatype: CreateUserDto,
      data: '',
    };

    const result = await pipe.transform(dtoWithExtra, metadata);

    expect(result).not.toHaveProperty('maliciousField');
  });
});

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

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

  it('should parse valid string to integer', () => {
    const result = pipe.transform('123', {
      type: 'param',
      metatype: Number,
      data: 'id',
    });

    expect(result).toBe(123);
    expect(typeof result).toBe('number');
  });

  it('should throw error for invalid string', () => {
    expect(() => 
      pipe.transform('not-a-number', {
        type: 'param',
        metatype: Number,
        data: 'id',
      })
    ).toThrow(BadRequestException);
  });

  it('should handle edge cases', () => {
    // Test mit 0
    expect(pipe.transform('0', {
      type: 'param',
      metatype: Number,
      data: 'id',
    })).toBe(0);

    // Test mit negativen Zahlen
    expect(pipe.transform('-42', {
      type: 'param',
      metatype: Number,
      data: 'id',
    })).toBe(-42);
  });
});

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

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

  it('should sanitize HTML content', () => {
    const dirtyData = {
      content: '<script>alert("xss")</script>Safe content',
      title: 'Clean title',
    };

    const result = pipe.transform(dirtyData, {
      type: 'body',
      metatype: Object,
      data: '',
    });

    expect(result.content).toBe('Safe content');
    expect(result.title).toBe('Clean title');
  });

  it('should normalize email addresses', () => {
    const data = {
      email: '  TEST@EXAMPLE.COM  ',
    };

    const result = pipe.transform(data, {
      type: 'body',
      metatype: Object,
      data: '',
    });

    expect(result.email).toBe('test@example.com');
  });

  it('should handle nested objects', () => {
    const nestedData = {
      user: {
        profile: {
          bio: '<b>Bold</b> content',
        },
      },
    };

    const result = pipe.transform(nestedData, {
      type: 'body',
      metatype: Object,
      data: '',
    });

    expect(result.user.profile.bio).toBe('Bold content');
  });
});

16.11 Mocking und Test Doubles

Effektives Mocking ist essentiell für isolierte Unit-Tests und performante Test-Suites.

16.11.1 Verschiedene Mocking-Strategien

// Advanced Mocking Strategies
describe('Advanced Mocking Patterns', () => {
  describe('Spy vs Mock vs Stub', () => {
    let originalMethod: any;
    let testObject: any;

    beforeEach(() => {
      testObject = {
        getData: () => 'original data',
        processData: (data: string) => `processed: ${data}`,
        saveData: (data: string) => console.log(`saving: ${data}`),
      };
      originalMethod = testObject.getData;
    });

    afterEach(() => {
      // Restore original method if needed
      testObject.getData = originalMethod;
    });

    it('should use spy to monitor method calls', () => {
      // Spy - überwacht Methodenaufrufe ohne das Verhalten zu ändern
      const spy = jest.spyOn(testObject, 'getData');

      const result = testObject.getData();

      expect(spy).toHaveBeenCalled();
      expect(spy).toHaveBeenCalledTimes(1);
      expect(result).toBe('original data'); // Original behavior preserved
    });

    it('should use mock to replace method implementation', () => {
      // Mock - ersetzt das Verhalten komplett
      const mock = jest.spyOn(testObject, 'getData')
        .mockReturnValue('mocked data');

      const result = testObject.getData();

      expect(mock).toHaveBeenCalled();
      expect(result).toBe('mocked data'); // Behavior changed
    });

    it('should use stub for side-effect methods', () => {
      // Stub - verhindert Seiteneffekte
      const stub = jest.spyOn(testObject, 'saveData')
        .mockImplementation(() => {}); // Do nothing

      testObject.saveData('test data');

      expect(stub).toHaveBeenCalledWith('test data');
      // No console.log output because it's stubbed
    });
  });

  describe('Complex Mock Scenarios', () => {
    interface PaymentGateway {
      processPayment(amount: number, cardToken: string): Promise<PaymentResult>;
      refundPayment(transactionId: string): Promise<RefundResult>;
    }

    interface PaymentResult {
      success: boolean;
      transactionId?: string;
      errorCode?: string;
      errorMessage?: string;
    }

    interface RefundResult {
      success: boolean;
      refundId?: string;
    }

    let mockPaymentGateway: jest.Mocked<PaymentGateway>;
    let paymentService: PaymentService;

    beforeEach(() => {
      // Erstelle typisierte Mocks
      mockPaymentGateway = {
        processPayment: jest.fn(),
        refundPayment: jest.fn(),
      };

      paymentService = new PaymentService(mockPaymentGateway);
    });

    it('should handle successful payment', async () => {
      // Konfiguriere Mock für erfolgreiche Zahlung
      mockPaymentGateway.processPayment.mockResolvedValue({
        success: true,
        transactionId: 'txn_123456',
      });

      const result = await paymentService.chargeCustomer(99.99, 'card_token');

      expect(result.success).toBe(true);
      expect(mockPaymentGateway.processPayment).toHaveBeenCalledWith(99.99, 'card_token');
    });

    it('should handle payment failures with retry', async () => {
      // Erstes Mal fehlschlagen, zweites Mal erfolgreich
      mockPaymentGateway.processPayment
        .mockRejectedValueOnce(new Error('Network timeout'))
        .mockResolvedValueOnce({
          success: true,
          transactionId: 'txn_retry_123',
        });

      const result = await paymentService.chargeCustomer(99.99, 'card_token');

      expect(result.success).toBe(true);
      expect(mockPaymentGateway.processPayment).toHaveBeenCalledTimes(2);
    });

    it('should handle payment gateway errors', async () => {
      mockPaymentGateway.processPayment.mockResolvedValue({
        success: false,
        errorCode: 'INSUFFICIENT_FUNDS',
        errorMessage: 'Card has insufficient funds',
      });

      const result = await paymentService.chargeCustomer(99.99, 'card_token');

      expect(result.success).toBe(false);
      expect(result.errorCode).toBe('INSUFFICIENT_FUNDS');
    });

    it('should track mock call history', async () => {
      // Mehrere Zahlungen durchführen
      await paymentService.chargeCustomer(25.00, 'card_1');
      await paymentService.chargeCustomer(50.00, 'card_2');
      await paymentService.chargeCustomer(75.00, 'card_1'); // Gleiche Karte

      // Überprüfe alle Aufrufe
      expect(mockPaymentGateway.processPayment).toHaveBeenCalledTimes(3);
      expect(mockPaymentGateway.processPayment).toHaveBeenNthCalledWith(1, 25.00, 'card_1');
      expect(mockPaymentGateway.processPayment).toHaveBeenNthCalledWith(2, 50.00, 'card_2');
      expect(mockPaymentGateway.processPayment).toHaveBeenNthCalledWith(3, 75.00, 'card_1');

      // Überprüfe, dass bestimmte Kombination aufgerufen wurde
      expect(mockPaymentGateway.processPayment).toHaveBeenCalledWith(
        expect.any(Number),
        'card_1'
      );
    });
  });
});

// Mock Factory Pattern
export class MockFactory {
  static createMockRepository<T>(): jest.Mocked<Repository<T>> {
    return {
      find: jest.fn(),
      findOne: jest.fn(),
      findOneBy: jest.fn(),
      save: jest.fn(),
      create: jest.fn(),
      update: jest.fn(),
      delete: jest.fn(),
      remove: jest.fn(),
      createQueryBuilder: jest.fn(() => ({
        where: jest.fn().mockReturnThis(),
        andWhere: jest.fn().mockReturnThis(),
        orWhere: jest.fn().mockReturnThis(),
        orderBy: jest.fn().mockReturnThis(),
        skip: jest.fn().mockReturnThis(),
        take: jest.fn().mockReturnThis(),
        leftJoin: jest.fn().mockReturnThis(),
        leftJoinAndSelect: jest.fn().mockReturnThis(),
        getMany: jest.fn(),
        getOne: jest.fn(),
        getManyAndCount: jest.fn(),
        getRawMany: jest.fn(),
        getRawOne: jest.fn(),
      })),
      manager: {
        transaction: jest.fn(),
        save: jest.fn(),
        create: jest.fn(),
        findOne: jest.fn(),
      },
    } as any;
  }

  static createMockConfigService(config: Record<string, any> = {}): jest.Mocked<ConfigService> {
    return {
      get: jest.fn((key: string, defaultValue?: any) => 
        config[key] !== undefined ? config[key] : defaultValue
      ),
      getOrThrow: jest.fn((key: string) => {
        if (config[key] === undefined) {
          throw new Error(`Configuration key "${key}" not found`);
        }
        return config[key];
      }),
    } as any;
  }

  static createMockEmailService(): jest.Mocked<EmailService> {
    return {
      sendEmail: jest.fn().mockResolvedValue(undefined),
      sendWelcomeEmail: jest.fn().mockResolvedValue(undefined),
      sendPasswordResetEmail: jest.fn().mockResolvedValue(undefined),
      sendNotificationEmail: jest.fn().mockResolvedValue(undefined),
    } as any;
  }
}

// Usage of Mock Factory
describe('Service with Mock Factory', () => {
  let service: UsersService;
  let mockRepository: jest.Mocked<Repository<User>>;
  let mockEmailService: jest.Mocked<EmailService>;
  let mockConfigService: jest.Mocked<ConfigService>;

  beforeEach(async () => {
    mockRepository = MockFactory.createMockRepository<User>();
    mockEmailService = MockFactory.createMockEmailService();
    mockConfigService = MockFactory.createMockConfigService({
      'EMAIL_ENABLED': true,
      'MAX_LOGIN_ATTEMPTS': 5,
    });

    const module = await Test.createTestingModule({
      providers: [
        UsersService,
        { provide: getRepositoryToken(User), useValue: mockRepository },
        { provide: EmailService, useValue: mockEmailService },
        { provide: ConfigService, useValue: mockConfigService },
      ],
    }).compile();

    service = module.get<UsersService>(UsersService);
  });

  it('should use factory-created mocks', async () => {
    const userData = { email: 'test@example.com', firstName: 'Test' };
    const savedUser = { id: '1', ...userData };

    mockRepository.create.mockReturnValue(savedUser as User);
    mockRepository.save.mockResolvedValue(savedUser as User);

    const result = await service.create(userData as any);

    expect(result).toEqual(savedUser);
    expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith(
      userData.email,
      userData.firstName
    );
  });
});

16.11.2 Partial Mocks und Smart Defaults

describe('Partial Mocking Strategies', () => {
  describe('Smart Default Mocks', () => {
    class SmartMockRepository {
      private data: any[] = [];
      private idCounter = 1;

      // Implementiere realistische Standard-Verhalten
      create(entityData: any) {
        return { id: this.idCounter++, ...entityData };
      }

      async save(entity: any) {
        if (entity.id) {
          // Update existing
          const index = this.data.findIndex(item => item.id === entity.id);
          if (index >= 0) {
            this.data[index] = { ...this.data[index], ...entity };
            return this.data[index];
          }
        }
        
        // Create new
        const newEntity = { ...entity, id: entity.id || this.idCounter++ };
        this.data.push(newEntity);
        return newEntity;
      }

      async findOne(options: any) {
        if (options.where) {
          return this.data.find(item => 
            Object.keys(options.where).every(key => 
              item[key] === options.where[key]
            )
          ) || null;
        }
        return this.data[0] || null;
      }

      async find(options: any = {}) {
        let result = [...this.data];
        
        if (options.where) {
          result = result.filter(item =>
            Object.keys(options.where).every(key =>
              item[key] === options.where[key]
            )
          );
        }
        
        if (options.take) {
          result = result.slice(0, options.take);
        }
        
        if (options.skip) {
          result = result.slice(options.skip);
        }
        
        return result;
      }

      async remove(entity: any) {
        const index = this.data.findIndex(item => item.id === entity.id);
        if (index >= 0) {
          this.data.splice(index, 1);
        }
      }

      async clear() {
        this.data = [];
        this.idCounter = 1;
      }

      // Zusätzliche Hilfsmethoden für Tests
      setTestData(data: any[]) {
        this.data = data;
      }

      getTestData() {
        return [...this.data];
      }
    }

    let smartMock: SmartMockRepository;
    let service: UsersService;

    beforeEach(async () => {
      smartMock = new SmartMockRepository();

      const module = await Test.createTestingModule({
        providers: [
          UsersService,
          { provide: getRepositoryToken(User), useValue: smartMock },
        ],
      }).compile();

      service = module.get<UsersService>(UsersService);
    });

    it('should work with realistic mock behavior', async () => {
      // Erstelle User
      const user1 = await service.create({
        email: 'user1@test.com',
        firstName: 'User',
        lastName: 'One',
      });

      expect(user1.id).toBeDefined();

      // Finde User
      const foundUser = await service.findById(user1.id);
      expect(foundUser).toEqual(user1);

      // Erstelle zweiten User
      const user2 = await service.create({
        email: 'user2@test.com',
        firstName: 'User',
        lastName: 'Two',
      });

      // Beide User sollten unterschiedliche IDs haben
      expect(user2.id).not.toBe(user1.id);

      // Liste alle User
      const allUsers = await service.findAll();
      expect(allUsers).toHaveLength(2);
    });

    it('should handle updates correctly', async () => {
      // Seed initial data
      smartMock.setTestData([
        { id: 1, email: 'existing@test.com', firstName: 'Existing' },
      ]);

      const updatedUser = await service.update(1, { firstName: 'Updated' });
      
      expect(updatedUser.firstName).toBe('Updated');
      expect(updatedUser.email).toBe('existing@test.com'); // Unchanged
    });
  });

  describe('Selective Mocking', () => {
    let realUserService: UsersService;
    let mockEmailService: jest.Mocked<EmailService>;

    beforeEach(async () => {
      // Nur bestimmte Dependencies mocken
      mockEmailService = {
        sendWelcomeEmail: jest.fn(),
        sendPasswordResetEmail: jest.fn(),
      } as any;

      const module = await Test.createTestingModule({
        imports: [
          TypeOrmModule.forRoot({
            type: 'sqlite',
            database: ':memory:',
            entities: [User],
            synchronize: true,
          }),
          TypeOrmModule.forFeature([User]),
        ],
        providers: [
          UsersService,
          { provide: EmailService, useValue: mockEmailService },
        ],
      }).compile();

      realUserService = module.get<UsersService>(UsersService);
    });

    it('should use real database but mock external services', async () => {
      const userData = {
        email: 'selective@test.com',
        firstName: 'Selective',
        lastName: 'Test',
        password: 'password123',
      };

      // Nutze echte Datenbank für User-Operationen
      const createdUser = await realUserService.create(userData);
      
      // Aber mocke externe E-Mail-Service
      expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith(
        userData.email,
        userData.firstName
      );

      // Verifiziere, dass User wirklich in Datenbank gespeichert wurde
      const foundUser = await realUserService.findById(createdUser.id);
      expect(foundUser.email).toBe(userData.email);
    });
  });
});

16.12 Coverage Reports und Quality Gates

Code Coverage ist ein wichtiger Indikator für die Qualität unserer Test-Suite, sollte aber nicht als alleiniges Maß betrachtet werden.

16.12.1 Coverage-Konfiguration

// jest.config.js - Erweiterte Coverage-Konfiguration
module.exports = {
  preset: '@nestjs/testing',
  
  // Coverage-Sammlung
  collectCoverage: true,
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.spec.ts',
    '!src/**/*.e2e-spec.ts',
    '!src/**/*.interface.ts',
    '!src/**/*.dto.ts',
    '!src/**/*.entity.ts',
    '!src/main.ts',
    '!src/**/*.module.ts',
  ],
  
  // Coverage-Reporter
  coverageReporters: [
    'text',           // Console output
    'text-summary',   // Summary in console
    'html',           // HTML report
    'lcov',           // For CI/CD integration
    'cobertura',      // For some CI systems
    'json',           // Machine-readable format
  ],
  
  // Coverage-Verzeichnisse
  coverageDirectory: '<rootDir>/coverage',
  
  // Coverage-Schwellenwerte
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
    // Spezifische Schwellenwerte für kritische Module
    './src/auth/': {
      branches: 90,
      functions: 90,
      lines: 90,
      statements: 90,
    },
    './src/payment/': {
      branches: 95,
      functions: 95,
      lines: 95,
      statements: 95,
    },
  },
  
  // Test-Umgebung
  testEnvironment: 'node',
  
  // Test-Muster
  testMatch: [
    '**/__tests__/**/*.(test|spec).ts',
    '**/*.(test|spec).ts',
  ],
  
  // Setup-Dateien
  setupFilesAfterEnv: ['<rootDir>/test/setup.ts'],
  
  // Module-Mapping für einfachere Imports
  moduleNameMapping: {
    '^@/(.*): '<rootDir>/src/$1',
    '^@test/(.*): '<rootDir>/test/$1',
  },
  
  // Ignoriere bestimmte Dateien für Coverage
  coveragePathIgnorePatterns: [
    '/node_modules/',
    '/dist/',
    '/coverage/',
    '.*\\.d\\.ts,
  ],
};

16.12.2 Custom Coverage-Reporter

// test/coverage-reporter.js
class CustomCoverageReporter {
  constructor(globalConfig, options) {
    this.globalConfig = globalConfig;
    this.options = options;
  }

  onRunComplete(contexts, results) {
    const { coverageMap } = results;
    
    if (!coverageMap) {
      console.log('No coverage data available');
      return;
    }

    const summary = coverageMap.getCoverageSummary();
    const metrics = {
      statements: summary.statements.pct,
      branches: summary.branches.pct,
      functions: summary.functions.pct,
      lines: summary.lines.pct,
    };

    // Erstelle detaillierte Berichte
    this.generateDetailedReport(coverageMap);
    this.checkQualityGates(metrics);
    this.generateTrendReport(metrics);
  }

  generateDetailedReport(coverageMap) {
    console.log('\n📊 Detailed Coverage Report:');
    console.log('='.repeat(50));

    coverageMap.files().forEach(file => {
      const fileCoverage = coverageMap.fileCoverageFor(file);
      const summary = fileCoverage.toSummary();
      
      const relativePath = file.replace(process.cwd(), '');
      
      // Farbige Ausgabe basierend auf Coverage
      const getColor = (pct) => {
        if (pct >= 80) return '\x1b[32m'; // Grün
        if (pct >= 60) return '\x1b[33m'; // Gelb
        return '\x1b[31m'; // Rot
      };

      const resetColor = '\x1b[0m';
      
      console.log(
        `${relativePath}: ` +
        `${getColor(summary.lines.pct)}${summary.lines.pct.toFixed(1)}%${resetColor} lines, ` +
        `${getColor(summary.branches.pct)}${summary.branches.pct.toFixed(1)}%${resetColor} branches`
      );
    });
  }

  checkQualityGates(metrics) {
    console.log('\n🚦 Quality Gates:');
    console.log('='.repeat(30));

    const gates = [
      { name: 'Statements', value: metrics.statements, threshold: 80 },
      { name: 'Branches', value: metrics.branches, threshold: 80 },
      { name: 'Functions', value: metrics.functions, threshold: 80 },
      { name: 'Lines', value: metrics.lines, threshold: 80 },
    ];

    let allPassed = true;

    gates.forEach(gate => {
      const passed = gate.value >= gate.threshold;
      const status = passed ? '✅ PASS' : '❌ FAIL';
      const color = passed ? '\x1b[32m' : '\x1b[31m';
      
      console.log(
        `${gate.name}: ${color}${gate.value.toFixed(1)}%\x1b[0m (threshold: ${gate.threshold}%) ${status}`
      );
      
      if (!passed) allPassed = false;
    });

    if (!allPassed) {
      console.log('\n❌ Some quality gates failed!');
      process.exit(1);
    } else {
      console.log('\n✅ All quality gates passed!');
    }
  }

  generateTrendReport(currentMetrics) {
    const fs = require('fs');
    const path = require('path');
    
    const trendsFile = path.join(process.cwd(), 'coverage-trends.json');
    let trends = [];

    // Lade vorherige Trends
    if (fs.existsSync(trendsFile)) {
      try {
        trends = JSON.parse(fs.readFileSync(trendsFile, 'utf8'));
      } catch (error) {
        console.warn('Could not read coverage trends file');
      }
    }

    // Füge aktuelle Metriken hinzu
    trends.push({
      timestamp: new Date().toISOString(),
      commit: process.env.GIT_COMMIT || 'unknown',
      ...currentMetrics,
    });

    // Behalte nur die letzten 50 Einträge
    if (trends.length > 50) {
      trends = trends.slice(-50);
    }

    // Speichere Trends
    fs.writeFileSync(trendsFile, JSON.stringify(trends, null, 2));

    // Zeige Trend-Analyse
    if (trends.length > 1) {
      const previous = trends[trends.length - 2];
      const current = trends[trends.length - 1];
      
      console.log('\n📈 Coverage Trends:');
      console.log('='.repeat(30));
      
      Object.keys(currentMetrics).forEach(metric => {
        const diff = current[metric] - previous[metric];
        const arrow = diff > 0 ? '📈' : diff < 0 ? '📉' : '➡️';
        const sign = diff > 0 ? '+' : '';
        
        console.log(
          `${metric}: ${current[metric].toFixed(1)}% (${sign}${diff.toFixed(1)}%) ${arrow}`
        );
      });
    }
  }
}

module.exports = CustomCoverageReporter;

16.12.3 CI/CD Integration

# .github/workflows/test-coverage.yml
name: Test Coverage

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test-coverage:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0  # Needed for trend analysis

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests with coverage
        run: npm run test:cov
        env:
          GIT_COMMIT: ${{ github.sha }}

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          file: ./coverage/lcov.info
          flags: unittests
          name: codecov-umbrella

      - name: SonarCloud Scan
        uses: SonarSource/sonarcloud-github-action@master
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

      - name: Coverage Badge
        uses: tj-actions/coverage-badge-action@v1
        with:
          coverage-file: coverage/coverage-summary.json

      - name: Quality Gate Check
        run: |
          # Extrahiere Coverage-Metriken
          COVERAGE=$(cat coverage/coverage-summary.json | jq -r '.total.lines.pct')
          
          if (( $(echo "$COVERAGE < 80" | bc -l) )); then
            echo "Coverage $COVERAGE% is below threshold of 80%"
            exit 1
          fi
          
          echo "Coverage $COVERAGE% meets quality standards"

      - name: Comment PR
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v6
        with:
          script: |
            const fs = require('fs');
            const coverage = JSON.parse(fs.readFileSync('coverage/coverage-summary.json'));
            
            const comment = `
            ## 📊 Test Coverage Report
            
            | Metric | Percentage | Status |
            |--------|------------|--------|
            | Statements | ${coverage.total.statements.pct}% | ${coverage.total.statements.pct >= 80 ? '✅' : '❌'} |
            | Branches | ${coverage.total.branches.pct}% | ${coverage.total.branches.pct >= 80 ? '✅' : '❌'} |
            | Functions | ${coverage.total.functions.pct}% | ${coverage.total.functions.pct >= 80 ? '✅' : '❌'} |
            | Lines | ${coverage.total.lines.pct}% | ${coverage.total.lines.pct >= 80 ? '✅' : '❌'} |
            
            [View detailed coverage report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
            `;
            
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: comment
            });

16.13 Best Practices für NestJS-Testing

Lassen Sie uns mit den wichtigsten Best Practices abschließen, die Ihnen dabei helfen, eine robuste und wartbare Test-Suite aufzubauen.

16.13.1 Test-Organisation und -Struktur

// Beispiel für gut organisierte Test-Struktur
describe('UsersService', () => {
  // Gruppiere zusammengehörige Tests
  describe('User Creation', () => {
    describe('with valid data', () => {
      it('should create user with all required fields', () => {});
      it('should hash password before saving', () => {});
      it('should send welcome email', () => {});
      it('should set default role to user', () => {});
    });

    describe('with invalid data', () => {
      it('should reject duplicate email', () => {});
      it('should reject weak password', () => {});
      it('should reject invalid email format', () => {});
    });

    describe('with edge cases', () => {
      it('should handle email with special characters', () => {});
      it('should handle very long names', () => {});
      it('should handle unicode characters', () => {});
    });
  });

  describe('User Authentication', () => {
    describe('successful login', () => {
      it('should return JWT token', () => {});
      it('should update last login timestamp', () => {});
      it('should reset failed login attempts', () => {});
    });

    describe('failed login', () => {
      it('should increment failed attempts counter', () => {});
      it('should lock account after max attempts', () => {});
      it('should log security event', () => {});
    });
  });
});

16.13.2 Test-Daten-Management

// test/fixtures/user.fixtures.ts
export class UserFixtures {
  static validUser() {
    return {
      email: 'valid@example.com',
      firstName: 'John',
      lastName: 'Doe',
      password: 'SecurePassword123!',
    };
  }

  static adminUser() {
    return {
      ...this.validUser(),
      email: 'admin@example.com',
      role: 'admin',
    };
  }

  static userWithWeakPassword() {
    return {
      ...this.validUser(),
      password: '123',
    };
  }

  static userWithInvalidEmail() {
    return {
      ...this.validUser(),
      email: 'invalid-email',
    };
  }

  static createUsersArray(count: number) {
    return Array.from({ length: count }, (_, i) => ({
      ...this.validUser(),
      email: `user${i}@example.com`,
      firstName: `User${i}`,
    }));
  }
}

// Verwendung in Tests
describe('UsersService', () => {
  it('should create user with valid data', async () => {
    const userData = UserFixtures.validUser();
    const result = await service.create(userData);
    expect(result.email).toBe(userData.email);
  });
});

16.13.3 Performance-optimierte Tests

// test/helpers/performance.helper.ts
export class PerformanceTestHelper {
  static measureExecutionTime<T>(fn: () => Promise<T>): Promise<{ result: T; duration: number }> {
    return new Promise(async (resolve) => {
      const start = process.hrtime.bigint();
      const result = await fn();
      const end = process.hrtime.bigint();
      const duration = Number(end - start) / 1000000; // Convert to milliseconds
      
      resolve({ result, duration });
    });
  }

  static async expectExecutionTime<T>(
    fn: () => Promise<T>,
    maxDuration: number
  ): Promise<T> {
    const { result, duration } = await this.measureExecutionTime(fn);
    
    if (duration > maxDuration) {
      throw new Error(
        `Execution took ${duration}ms, expected less than ${maxDuration}ms`
      );
    }
    
    return result;
  }
}

// Verwendung in Tests
describe('Performance Tests', () => {
  it('should find users within acceptable time', async () => {
    await PerformanceTestHelper.expectExecutionTime(
      () => usersService.findAll(),
      100 // Max 100ms
    );
  });

  it('should handle large datasets efficiently', async () => {
    // Seed 1000 users
    const users = UserFixtures.createUsersArray(1000);
    await Promise.all(users.map(user => usersService.create(user)));

    const { duration } = await PerformanceTestHelper.measureExecutionTime(
      () => usersService.findPaginated(1, 50)
    );

    expect(duration).toBeLessThan(200); // Should complete within 200ms
  });
});

16.13.4 Test-Wartbarkeit

// Verwende Page Object Pattern für E2E-Tests
class LoginPage {
  constructor(private page: any) {}

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

  async fillCredentials(email: string, password: string) {
    await this.page.fill('[data-testid=email-input]', email);
    await this.page.fill('[data-testid=password-input]', password);
  }

  async submitForm() {
    await this.page.click('[data-testid=login-button]');
  }

  async getErrorMessage() {
    return this.page.textContent('[data-testid=error-message]');
  }

  async login(email: string, password: string) {
    await this.navigateToLogin();
    await this.fillCredentials(email, password);
    await this.submitForm();
  }
}

// Verwendung in Tests
describe('Login Flow', () => {
  let loginPage: LoginPage;

  beforeEach(() => {
    loginPage = new LoginPage(page);
  });

  it('should login successfully with valid credentials', async () => {
    await loginPage.login('valid@example.com', 'password123');
    await expect(page).toHaveURL(/dashboard/);
  });
});

Testing ist weit mehr als nur eine Notwendigkeit – es ist ein mächtiges Werkzeug, das uns dabei hilft, besseren Code zu schreiben und Vertrauen in unsere Anwendungen zu entwickeln. Mit den in diesem Kapitel vorgestellten Techniken und Patterns können Sie eine robuste, wartbare und effiziente Test-Suite für Ihre NestJS-Anwendungen aufbauen.

Denken Sie daran: Gute Tests sind eine Investition in die Zukunft Ihrer Anwendung. Sie sparen Zeit bei der Entwicklung, reduzieren Bugs in der Produktion und ermöglichen es Ihnen, mit Vertrauen zu refactoren und neue Features hinzuzufügen.