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

# Flutter Testing Guide: Unit-, Widget- und Integrationstests

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
class=class="code-string">"code-comment">// test/services/price_calculator_test.dart
import class="code-string">'package:flutter_test/flutter_test.dart';
import class="code-string">'package:my_app/services/price_calculator.dart';

void main() {
  late PriceCalculator calculator;

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

  group(class="code-string">'PriceCalculator', () {
    test(class="code-string">'wendet prozentualen Rabatt korrekt an', () {
      final result = calculator.applyDiscount(class="code-number">100.0, percentage: class="code-number">20);
      expect(result, class="code-number">80.0);
    });

    test(class="code-string">'erlaubt keine negativen Preise', () {
      final result = calculator.applyDiscount(class="code-number">10.0, percentage: class="code-number">150);
      expect(result, class="code-number">0.0);
    });

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

    test(class="code-string">'berechnet Steuer korrekt', () {
      final result = calculator.withTax(class="code-number">100.0, taxRate: class="code-number">0.19);
      expect(result, closeTo(class="code-number">119.0, class="code-number">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
class=class="code-string">"code-comment">// test/widgets/login_form_test.dart
import class="code-string">'package:flutter/material.dart';
import class="code-string">'package:flutter_test/flutter_test.dart';
import class="code-string">'package:my_app/widgets/login_form.dart';

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

      class=class="code-string">"code-comment">// Absenden-Button drücken ohne Eingabe
      await tester.tap(find.byType(ElevatedButton));
      await tester.pumpAndSettle();

      expect(find.text(class="code-string">'E-Mail ist erforderlich'), findsOneWidget);
    });

    testWidgets(class="code-string">'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(class="code-string">'email_field')),
        class="code-string">'benutzer@beispiel.de',
      );
      await tester.enterText(
        find.byKey(const Key(class="code-string">'password_field')),
        class="code-string">'sicheresPasswort123',
      );
      await tester.tap(find.byType(ElevatedButton));
      await tester.pumpAndSettle();

      expect(gesendeteEmail, class="code-string">'benutzer@beispiel.de');
      expect(gesendetesPasswort, class="code-string">'sicheresPasswort123');
    });

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

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

      class=class="code-string">"code-comment">// Sichtbarkeits-Icon drücken
      await tester.tap(find.byIcon(Icons.visibility_off));
      await tester.pump();

      final updatedField = tester.widget<TextField>(
        find.byKey(const Key(class="code-string">'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
class=class="code-string">"code-comment">// integration_test/app_test.dart
import class="code-string">'package:flutter_test/flutter_test.dart';
import class="code-string">'package:integration_test/integration_test.dart';
import class="code-string">'package:my_app/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

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

      class=class="code-string">"code-comment">// Anmeldedaten eingeben
      await tester.enterText(
        find.byKey(const Key(class="code-string">'email_field')),
        class="code-string">'test@beispiel.de',
      );
      await tester.enterText(
        find.byKey(const Key(class="code-string">'password_field')),
        class="code-string">'passwort123',
      );

      class=class="code-string">"code-comment">// Absenden
      await tester.tap(find.byKey(const Key(class="code-string">'login_button')));
      await tester.pumpAndSettle(const Duration(seconds: class="code-number">3));

      class=class="code-string">"code-comment">// Navigation zum Startbildschirm prüfen
      expect(find.text(class="code-string">'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
class=class="code-string">"code-comment">// test/repositories/user_repository_test.dart
import class="code-string">'package:flutter_test/flutter_test.dart';
import class="code-string">'package:mockito/annotations.dart';
import class="code-string">'package:mockito/mockito.dart';
import class="code-string">'package:my_app/services/api_client.dart';
import class="code-string">'package:my_app/models/user.dart';
import class="code-string">'package:my_app/repositories/user_repository.dart';

@GenerateMocks([ApiClient])
import class="code-string">'user_repository_test.mocks.dart';

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

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

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

      final user = await repository.getUser(class="code-number">1);

      expect(user.name, class="code-string">'Anna');
      expect(user.email, class="code-string">'anna@test.de');
      verify(mockApiClient.get(class="code-string">'/users/class="code-number">1')).called(class="code-number">1);
    });

    test(class="code-string">'wirft UserNotFoundException wenn API class="code-number">404 zurückgibt', () async {
      when(mockApiClient.get(class="code-string">'/users/class="code-number">999')).thenThrow(
        ApiException(statusCode: class="code-number">404, message: class="code-string">'Nicht gefunden'),
      );

      expect(
        () => repository.getUser(class="code-number">999),
        throwsA(isA<UserNotFoundException>()),
      );
    });

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

      await repository.getUser(class="code-number">1);
      await repository.getUser(class="code-number">1);

      class=class="code-string">"code-comment">// Durch Caching sollte die API nur einmal aufgerufen werden
      verify(mockApiClient.get(class="code-string">'/users/class="code-number">1')).called(class="code-number">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
class=class="code-string">"code-comment">// test/widgets/profile_card_golden_test.dart
import class="code-string">'package:flutter/material.dart';
import class="code-string">'package:flutter_test/flutter_test.dart';
import class="code-string">'package:my_app/widgets/profile_card.dart';

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

      await expectLater(
        find.byType(ProfileCard),
        matchesGoldenFile(class="code-string">'goldens/profile_card_default.png'),
      );
    });

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

      await expectLater(
        find.byType(ProfileCard),
        matchesGoldenFile(class="code-string">'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(class="code-string">'AuthService', () {
  group(class="code-string">'login', () {
    test(class="code-string">'gelingt mit gültigen Anmeldedaten', () { class=class="code-string">"code-comment">/* ... */ });
    test(class="code-string">'schlägt mit falschem Passwort fehl', () { class=class="code-string">"code-comment">/* ... */ });
    test(class="code-string">'drosselt nach class="code-number">5 Fehlversuchen', () { class=class="code-string">"code-comment">/* ... */ });
  });

  group(class="code-string">'logout', () {
    test(class="code-string">'löscht gespeichertes Token', () { class=class="code-string">"code-comment">/* ... */ });
    test(class="code-string">'navigiert zum Login-Bildschirm', () { class=class="code-string">"code-comment">/* ... */ });
  });
});

Gemeinsame Test-Helfer

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

dart
class=class="code-string">"code-comment">// test/helpers/pump_app.dart
import class="code-string">'package:flutter/material.dart';
import class="code-string">'package:flutter_test/flutter_test.dart';

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

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

  static List<User> get beispielBenutzerliste => [
    beispielBenutzer,
    User(id: class="code-number">2, name: class="code-string">'Weiterer Benutzer', email: class="code-string">'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/.*: "code-comment">//' | 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