Flutter

Flutter Testing: Unit-, Widget- und Integrationstests

14 Min. Lesezeit9. Februar 2026Aktualisiert 9. März 2026
Flutter testingFlutter unit testFlutter widget testFlutter integration testFlutter test strategyFlutter test coverageDart testCI testing Flutter

Testen bremst nicht — es verhindert, dass teure Fehler in Produktion landen. Eine gut strukturierte Testsuite gibt Sicherheit beim Refactoring und dient gleichzeitig als lebendige Dokumentation für die gesamte Codebasis.

Die Testpyramide

Die Testpyramide bildet das Fundament jeder gesunden Teststrategie. Das Prinzip ist einfach: Viele schnelle, günstige Tests an der Basis, wenige langsame, umfangreiche Tests an der Spitze.

Unit-Tests

Unit-Tests prüfen einzelne Funktionen, Methoden und Klassen isoliert. Sie laufen am schnellsten, sind am einfachsten zu schreiben und sollten den Großteil der Testsuite ausmachen.

dart
// test/services/price_calculator_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/services/price_calculator.dart';

void main() {
  late PriceCalculator calculator;

  setUp(() {
    calculator = PriceCalculator();
  });

  group('PriceCalculator', () {
    test('wendet prozentualen Rabatt korrekt an', () {
      final result = calculator.applyDiscount(100.0, percentage: 20);
      expect(result, 80.0);
    });

    test('erlaubt keine negativen Preise', () {
      final result = calculator.applyDiscount(10.0, percentage: 150);
      expect(result, 0.0);
    });

    test('wirft Fehler bei ungültigem Prozentsatz', () {
      expect(
        () => calculator.applyDiscount(100.0, percentage: -5),
        throwsA(isA<ArgumentError>()),
      );
    });

    test('berechnet Steuer korrekt', () {
      final result = calculator.withTax(100.0, taxRate: 0.19);
      expect(result, closeTo(119.0, 0.01));
    });
  });
}

Widget-Tests

Widget-Tests rendern ein einzelnes Widget oder einen kleinen Widget-Baum und prüfen dessen Verhalten — Taps, Textdarstellung, Zustandsänderungen — ohne ein echtes Gerät.

dart
// test/widgets/login_form_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/widgets/login_form.dart';

void main() {
  group('LoginForm', () {
    testWidgets('zeigt Validierungsfehler bei leerer E-Mail',
        (WidgetTester tester) async {
      await tester.pumpWidget(
        const MaterialApp(home: Scaffold(body: LoginForm())),
      );

      // Absenden-Button drücken ohne Eingabe
      await tester.tap(find.byType(ElevatedButton));
      await tester.pumpAndSettle();

      expect(find.text('E-Mail ist erforderlich'), findsOneWidget);
    });

    testWidgets('ruft onSubmit mit korrekten Daten auf',
        (WidgetTester tester) async {
      String? gesendeteEmail;
      String? gesendetesPasswort;

      await tester.pumpWidget(
        MaterialApp(
          home: Scaffold(
            body: LoginForm(
              onSubmit: (email, password) {
                gesendeteEmail = email;
                gesendetesPasswort = password;
              },
            ),
          ),
        ),
      );

      await tester.enterText(
        find.byKey(const Key('email_field')),
        'benutzer@beispiel.de',
      );
      await tester.enterText(
        find.byKey(const Key('password_field')),
        'sicheresPasswort123',
      );
      await tester.tap(find.byType(ElevatedButton));
      await tester.pumpAndSettle();

      expect(gesendeteEmail, 'benutzer@beispiel.de');
      expect(gesendetesPasswort, 'sicheresPasswort123');
    });

    testWidgets('schaltet Passwort-Sichtbarkeit um',
        (WidgetTester tester) async {
      await tester.pumpWidget(
        const MaterialApp(home: Scaffold(body: LoginForm())),
      );

      // Passwort sollte standardmäßig verborgen sein
      final passwordField = tester.widget<TextField>(
        find.byKey(const Key('password_field')),
      );
      expect(passwordField.obscureText, isTrue);

      // Sichtbarkeits-Icon drücken
      await tester.tap(find.byIcon(Icons.visibility_off));
      await tester.pump();

      final updatedField = tester.widget<TextField>(
        find.byKey(const Key('password_field')),
      );
      expect(updatedField.obscureText, isFalse);
    });
  });
}

Integrationstests

Integrationstests laufen auf einem echten Gerät oder Emulator und prüfen vollständige Benutzerabläufe von Anfang bis Ende. Sie sind langsamer, fangen aber Probleme ab, die Unit- und Widget-Tests nicht erkennen können.

dart
// integration_test/app_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_app/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('End-to-End: Authentifizierungs-Flow', () {
    testWidgets('Benutzer kann sich einloggen und den Startbildschirm sehen',
        (WidgetTester tester) async {
      app.main();
      await tester.pumpAndSettle();

      // Anmeldedaten eingeben
      await tester.enterText(
        find.byKey(const Key('email_field')),
        'test@beispiel.de',
      );
      await tester.enterText(
        find.byKey(const Key('password_field')),
        'passwort123',
      );

      // Absenden
      await tester.tap(find.byKey(const Key('login_button')));
      await tester.pumpAndSettle(const Duration(seconds: 3));

      // Navigation zum Startbildschirm prüfen
      expect(find.text('Willkommen zurück!'), findsOneWidget);
      expect(find.byType(BottomNavigationBar), findsOneWidget);
    });
  });
}

Mocking mit Mockito

Reale Anwendungen hängen von APIs, Datenbanken und Plattformdiensten ab. Mocking ersetzt diese Abhängigkeiten durch kontrollierte Stellvertreter, sodass Tests schnell, deterministisch und offline-fähig werden.

dart
// test/repositories/user_repository_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:my_app/services/api_client.dart';
import 'package:my_app/models/user.dart';
import 'package:my_app/repositories/user_repository.dart';

@GenerateMocks([ApiClient])
import 'user_repository_test.mocks.dart';

void main() {
  late MockApiClient mockApiClient;
  late UserRepository repository;

  setUp(() {
    mockApiClient = MockApiClient();
    repository = UserRepository(apiClient: mockApiClient);
  });

  group('UserRepository', () {
    test('gibt Benutzer zurück wenn API-Aufruf erfolgreich ist', () async {
      when(mockApiClient.get('/users/1')).thenAnswer(
        (_) async => {'id': 1, 'name': 'Anna', 'email': 'anna@test.de'},
      );

      final user = await repository.getUser(1);

      expect(user.name, 'Anna');
      expect(user.email, 'anna@test.de');
      verify(mockApiClient.get('/users/1')).called(1);
    });

    test('wirft UserNotFoundException wenn API 404 zurückgibt', () async {
      when(mockApiClient.get('/users/999')).thenThrow(
        ApiException(statusCode: 404, message: 'Nicht gefunden'),
      );

      expect(
        () => repository.getUser(999),
        throwsA(isA<UserNotFoundException>()),
      );
    });

    test('cached Benutzer nach erstem Abruf', () async {
      when(mockApiClient.get('/users/1')).thenAnswer(
        (_) async => {'id': 1, 'name': 'Anna', 'email': 'anna@test.de'},
      );

      await repository.getUser(1);
      await repository.getUser(1);

      // Durch Caching sollte die API nur einmal aufgerufen werden
      verify(mockApiClient.get('/users/1')).called(1);
    });
  });
}

Nach dem Schreiben der Testdatei muss dart run build_runner build ausgeführt werden, um die .mocks.dart-Datei zu generieren.

Golden Tests

Golden Tests (auch Snapshot-Tests genannt) erfassen ein gerendertes Widget als Bild und vergleichen künftige Renderings mit dieser Referenz. Sie sind unschätzbar wertvoll, um unbeabsichtigte visuelle Regressionen aufzudecken.

dart
// test/widgets/profile_card_golden_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/widgets/profile_card.dart';

void main() {
  group('ProfileCard Golden Tests', () {
    testWidgets('entspricht der Standard-Darstellung',
        (WidgetTester tester) async {
      await tester.pumpWidget(
        MaterialApp(
          theme: ThemeData.light(),
          home: const Scaffold(
            body: ProfileCard(
              name: 'Anna Müller',
              role: 'Flutter-Entwicklerin',
              avatarUrl: null,
            ),
          ),
        ),
      );

      await expectLater(
        find.byType(ProfileCard),
        matchesGoldenFile('goldens/profile_card_default.png'),
      );
    });

    testWidgets('entspricht der Dark-Theme-Darstellung',
        (WidgetTester tester) async {
      await tester.pumpWidget(
        MaterialApp(
          theme: ThemeData.dark(),
          home: const Scaffold(
            body: ProfileCard(
              name: 'Anna Müller',
              role: 'Flutter-Entwicklerin',
              avatarUrl: null,
            ),
          ),
        ),
      );

      await expectLater(
        find.byType(ProfileCard),
        matchesGoldenFile('goldens/profile_card_dark.png'),
      );
    });
  });
}

Um die Referenzbilder zu erstellen, wird flutter test --update-goldens ausgeführt. Bei nachfolgenden Durchläufen vergleicht Flutter neue Renderings pixelgenau mit diesen Referenzen. Die Golden-Dateien gehören ins Versionskontrollsystem, damit das gesamte Team dieselbe Referenz nutzt.

Testorganisation

Eine einheitliche Teststruktur macht einen enormen Unterschied, wenn das Projekt wächst. Hier sind die Praktiken, die ich verfolge.

Dateinamen und Struktur

Die lib/-Ordnerstruktur sollte in test/ gespiegelt werden. Wenn es lib/services/auth_service.dart gibt, lebt der Test unter test/services/auth_service_test.dart. Diese Konvention macht es trivial, den Test zu jeder Datei zu finden.

test/
  services/
    auth_service_test.dart
    price_calculator_test.dart
  repositories/
    user_repository_test.dart
  widgets/
    login_form_test.dart
    profile_card_test.dart
    profile_card_golden_test.dart
  helpers/
    test_data.dart
    pump_app.dart
integration_test/
  app_test.dart

Tests gruppieren

Mit group() lassen sich zusammengehörige Assertions organisieren. Für komplexe Klassen mit mehreren Methoden können Gruppen verschachtelt werden.

dart
group('AuthService', () {
  group('login', () {
    test('gelingt mit gültigen Anmeldedaten', () { /* ... */ });
    test('schlägt mit falschem Passwort fehl', () { /* ... */ });
    test('drosselt nach 5 Fehlversuchen', () { /* ... */ });
  });

  group('logout', () {
    test('löscht gespeichertes Token', () { /* ... */ });
    test('navigiert zum Login-Bildschirm', () { /* ... */ });
  });
});

Gemeinsame Test-Helfer

Wiederverwendbare Setups werden in Hilfsdateien ausgelagert, um Duplizierung zu vermeiden.

dart
// test/helpers/pump_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

extension PumpApp on WidgetTester {
  Future<void> pumpApp(Widget widget) {
    return pumpWidget(
      MaterialApp(
        home: Scaffold(body: widget),
      ),
    );
  }
}
dart
// test/helpers/test_data.dart
import 'package:my_app/models/user.dart';

class TestData {
  static User get beispielBenutzer => User(
    id: 1,
    name: 'Testbenutzer',
    email: 'test@beispiel.de',
  );

  static List<User> get beispielBenutzerliste => [
    beispielBenutzer,
    User(id: 2, name: 'Weiterer Benutzer', email: 'weiterer@beispiel.de'),
  ];
}

Was man NICHT testen sollte

Alles zu testen ist weder praktikabel noch sinnvoll. Zu wissen, was man weglässt, ist genauso wichtig wie zu wissen, was man abdeckt.

  • Flutter-Framework-Interna. Nicht testen, ob Text('Hallo') den String "Hallo" rendert. Flutters eigene Tests decken das ab.
  • Generierter Code. Code, der von build_runner, json_serializable oder freezed erzeugt wird, ist maschinenerzeugt. Ihn zu testen fügt Rauschen hinzu, ohne Mehrwert zu liefern.
  • Triviale Getter und Setter. Ein Getter, der einfach ein Feld zurückgibt, hat keine Logik, die verifiziert werden müsste.
  • Verhalten von Drittanbieter-Paketen. Bei Paketen für HTTP oder Speicher dem eigenen Testsuite des Pakets vertrauen. An der Grenze mocken und die eigene Logik drumherum testen.
  • Eins-zu-eins-Implementierungsdetails. Tests, die die Implementierung zeilenweise spiegeln, brechen bei jedem Refactoring, ohne echte Fehler zu finden. Verhalten testen, nicht Implementierung.

Die Energie sollte auf Geschäftslogik, Grenzfälle, Fehlerbehandlung und kritische Benutzerabläufe konzentriert werden.

CI-Integration mit GitHub Actions

Das Automatisieren von Tests in CI ist für jedes Teamprojekt unverzichtbar. Hier ist ein GitHub-Actions-Workflow, der Flutter-Tests bei jedem Pull Request ausführt.

yaml
# .github/workflows/flutter_tests.yml
name: Flutter Tests

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

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: subosiro/flutter-action@v2
        with:
          flutter-version: '3.24.0'
          channel: 'stable'
          cache: true

      - name: Abhängigkeiten installieren
        run: flutter pub get

      - name: Formatierung prüfen
        run: dart format --set-exit-if-changed .

      - name: Statische Analyse
        run: flutter analyze --fatal-infos

      - name: Unit- und Widget-Tests mit Coverage ausführen
        run: flutter test --coverage

      - name: Mindest-Coverage prüfen
        run: |
          COVERAGE=$(lcov --summary coverage/lcov.info 2>&1 | grep 'lines' | sed 's/.*: //' | sed 's/%.*//')
          echo "Aktuelle Coverage: $COVERAGE%"
          if (( $(echo "$COVERAGE < 70" | bc -l) )); then
            echo "Coverage $COVERAGE% liegt unter der 70%-Schwelle"
            exit 1
          fi

      - name: Coverage-Bericht hochladen
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/lcov.info

Diese Pipeline umfasst Formatierungsprüfung, statische Analyse, Tests mit Coverage-Erfassung und eine Mindest-Coverage-Schwelle. Ein fehlgeschlagener Test blockiert den Merge — genau so soll es sein.

Persönliche Einblicke

In meinen Projekten strebe ich hohe Coverage in der Geschäftslogik und den Repository-Schichten an — in der Regel über 85% — während ich Widget-Test-Coverage auf interaktionsintensive Komponenten wie Formulare und Navigationsabläufe fokussiere. Ich jage keiner 100%-Coverage nach; die letzten 15% decken meist trivialen Code ab und kosten mehr in der Wartung, als sie an Sicherheit bringen.

Vor jeder Fehlerbehebung schreibe ich einen Test: Zuerst reproduziere ich den Fehler mit einem fehlschlagenden Test, dann behebe ich den Code und beobachte, wie der Test grün wird. Das garantiert, dass derselbe Fehler nie wieder auftritt.

Bei neuen Projekten richte ich CI mit Test-Pflicht am ersten Tag ein. Tests nachträglich hinzuzufügen kostet immer mehr, als sie von Anfang an mitzuschreiben.

Fazit

Eine ausgewogene Testsuite — viele Unit-Tests, gezielte Widget-Tests und eine Handvoll Integrationstests — gibt die Sicherheit, schnell zu liefern, ohne etwas kaputtzumachen. Kombiniert mit CI-Pflicht entsteht ein Workflow, der von Einzelprojekten bis zu großen Teams skaliert.

Gerne unterstütze ich bei einer passenden Teststrategie für Ihr Projekt.

Verwandte Artikel

Haben Sie ein Flutter-Projekt?

Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.

Kontakt aufnehmen