Flutter Testing: Unit-, Widget- und Integrationstests
# 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.
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.
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.
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.
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.
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.dartTests gruppieren
Mit `group()` lassen sich zusammengehörige Assertions organisieren. Für komplexe Klassen mit mehreren Methoden können Gruppen verschachtelt werden.
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.
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),
),
);
}
}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.
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.
# .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.infoDiese 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
Flutter State Management: Riverpod, Provider und Bloc im Vergleich
Vergleichen Sie State-Management-Ansätze in Flutter. Verstehen Sie Riverpod, Provider und Bloc mit klaren Entscheidungskriterien.
Clean Architecture in Flutter: Skalierbare Anwendungen entwickeln
Lernen Sie, Clean Architecture in Flutter praxisnah umzusetzen. Ein Leitfaden für Schichten, Dependency Management und testbaren Code.
Flutter CI/CD: Automatisierte Build-, Test- und Release-Pipeline
Entwerfen Sie eine robuste Flutter-CI/CD-Pipeline mit automatisierten Build-, Test- und Release-Schritten.
Haben Sie ein Flutter-Projekt?
Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.
Kontakt aufnehmen