test: add pumpApp helpers, fakes and coverage matrix template

Refs #31

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-02 20:21:55 +01:00
parent 936ca7bd4b
commit adf61b3ba2
8 changed files with 369 additions and 46 deletions

44
app/test/README.md Normal file
View file

@ -0,0 +1,44 @@
# Tests der App
Unit- und Widget-Tests spiegeln die Struktur von `lib/`. Integration (`integration_test/`)
und Web-E2E (Playwright) kommen mit den jeweiligen Feature-Tasks.
```bash
flutter test --coverage
```
## Helfer (`test/helpers/`)
| Datei | Inhalt |
|---|---|
| `pump_app.dart` | `pumpApp(tester, home: …)` bzw. `router: …`: MaterialApp mit AppTheme, l10n-Delegates, ProviderScope, wählbare `locale` (Standard `de`), `textScale`, `themeMode`. `pumpShuffleApp(tester, deviceLocale: …)` startet die echte `ShuffleApp`. Beide geben den `ProviderContainer` zurück. |
| `fakes.dart` | `MockSupabaseClient`, `MockGoTrueClient`, `SupabaseMocks` (Client mit verdrahtetem `auth`), `testAppConfig(…)`, `testOverrides(…)` (feste AppConfig + Supabase-Mock, weitere Overrides anhängbar). |
| `router_helpers.dart` | `buildTestRouter`, `FakeRedirectSource`, `currentPath`, `currentUri`. |
Regeln: keine echten Netzwerkaufrufe, feste Zeit (`clock`/`fakeAsync`), feste Zufallswerte.
Schlägt ein Test wegen eines Produktivcode-Fehlers fehl, wird der Test nicht angepasst,
sondern der Fehler an den Entwickler-Agent zurückgegeben.
## Benennung mit Akzeptanzkriterien
Tests, die ein Akzeptanzkriterium belegen, tragen die Referenz `KTO-01 Kriterium 3`
(Story-ID, Kriterium-Nummer wie im Issue) am Anfang des Namens:
```dart
group('KTO-01 Kriterium 3: Passwort-Richtlinie', () {
test('lehnt Passwörter unter 10 Zeichen ab', () { … });
});
testWidgets('KTO-01 Kriterium 7: zeigt bei falschen Zugangsdaten eine allgemeine Meldung', …);
```
- Eine Referenz pro `group`/Test; deckt ein Test mehrere Kriterien ab, alle nennen
(`KTO-01 Kriterium 1, 2`).
- Konfigurationsabhängige Tests nennen zusätzlich die Konfig-ID (`AUTH-CFG-10`).
- Sprache der Testnamen einheitlich Englisch (wie der Code); die Referenz bleibt unverändert.
- Suche nach Abdeckung: `grep -rn "KTO-01 Kriterium 3" test integration_test ../supabase/tests ../e2e`.
## Abdeckungsmatrix
Jeder PR enthält eine Matrix Akzeptanzkriterium → Test(s). Vorlage:
`docs/templates/abdeckungsmatrix.md`.

View file

@ -1,32 +1,20 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/app.dart';
Future<void> _pumpApp(WidgetTester tester) async { import 'helpers/pump_app.dart';
await tester.pumpWidget(const ProviderScope(child: ShuffleApp()));
await tester.pumpAndSettle();
}
void main() { void main() {
tearDown(() {
TestWidgetsFlutterBinding.instance.platformDispatcher
.clearLocalesTestValue();
});
testWidgets('app starts on the placeholder home page (English fallback)', ( testWidgets('app starts on the placeholder home page (English fallback)', (
tester, tester,
) async { ) async {
tester.platformDispatcher.localesTestValue = [const Locale('fr')]; await pumpShuffleApp(tester, deviceLocale: const Locale('fr'));
await _pumpApp(tester);
expect(find.byKey(const Key('home_screen')), findsOneWidget); expect(find.byKey(const Key('home_screen')), findsOneWidget);
expect(find.textContaining('Welcome to Shuffle'), findsOneWidget); expect(find.textContaining('Welcome to Shuffle'), findsOneWidget);
}); });
testWidgets('app shows German texts on a German device', (tester) async { testWidgets('app shows German texts on a German device', (tester) async {
tester.platformDispatcher.localesTestValue = [const Locale('de', 'DE')]; await pumpShuffleApp(tester, deviceLocale: const Locale('de', 'DE'));
await _pumpApp(tester);
expect(find.textContaining('Willkommen bei Shuffle'), findsOneWidget); expect(find.textContaining('Willkommen bei Shuffle'), findsOneWidget);
}); });

View file

@ -1,35 +1,17 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:shuffle/app.dart';
import 'package:shuffle/core/routing/app_router.dart'; import 'package:shuffle/core/routing/app_router.dart';
import 'package:shuffle/core/routing/redirect_source.dart'; import 'package:shuffle/core/routing/redirect_source.dart';
class _FakeRedirectSource implements RedirectSource { import '../../helpers/fakes.dart';
final notifier = ValueNotifier<int>(0); import '../../helpers/pump_app.dart';
String? target; import '../../helpers/router_helpers.dart';
@override
Listenable get changes => notifier;
@override
String? redirect(GoRouterState state) =>
state.uri.path == '/blocked' ? target : null;
}
void main() { void main() {
testWidgets('unknown path shows the 404 page and leads back home', ( testWidgets('unknown path shows the 404 page and leads back home', (
tester, tester,
) async { ) async {
final container = ProviderContainer.test(); final container = await pumpShuffleApp(tester);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const ShuffleApp(),
),
);
container.read(appRouterProvider).go('/does-not-exist'); container.read(appRouterProvider).go('/does-not-exist');
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -42,20 +24,18 @@ void main() {
}); });
testWidgets('redirect source decides where a route leads', (tester) async { testWidgets('redirect source decides where a route leads', (tester) async {
final source = _FakeRedirectSource()..target = '/'; final source = FakeRedirectSource({'/blocked': '/'});
final container = ProviderContainer.test( final container = await pumpShuffleApp(
tester,
overrides: testOverrides(
overrides: [redirectSourceProvider.overrideWithValue(source)], overrides: [redirectSourceProvider.overrideWithValue(source)],
);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const ShuffleApp(),
), ),
); );
container.read(appRouterProvider).go('/blocked'); final router = container.read(appRouterProvider)..go('/blocked');
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('home_screen')), findsOneWidget); expect(find.byKey(const Key('home_screen')), findsOneWidget);
expect(currentPath(router), '/');
}); });
} }

View file

@ -0,0 +1,55 @@
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:mocktail/mocktail.dart';
import 'package:shuffle/core/config/app_config.dart';
import 'package:shuffle/core/config/app_config_provider.dart';
import 'package:shuffle/core/supabase/supabase_client_provider.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
/// Mock des Supabase-Clients. Kein Netzwerk: nicht gestubbte Aufrufe schlagen
/// mit einem mocktail-Fehler fehl, statt still zu laufen.
class MockSupabaseClient extends Mock implements SupabaseClient {}
/// Mock der Auth-Schnittstelle (`client.auth`).
class MockGoTrueClient extends Mock implements GoTrueClient {}
/// Feste Test-Konfiguration ohne echte Zugangsdaten.
AppConfig testAppConfig({
String environment = 'test',
String supabaseUrl = 'http://localhost:54321',
String supabaseAnonKey = 'test-anon-key',
String siteUrl = 'http://localhost:3000',
String legalPrivacyUrl = 'https://example.test/privacy',
String legalTermsUrl = 'https://example.test/terms',
String termsVersion = 'test-1',
}) => AppConfig(
environment: environment,
supabaseUrl: supabaseUrl,
supabaseAnonKey: supabaseAnonKey,
siteUrl: siteUrl,
legalPrivacyUrl: legalPrivacyUrl,
legalTermsUrl: legalTermsUrl,
termsVersion: termsVersion,
);
/// Ein Mock-Client samt Auth-Mock: `client.auth` liefert [auth].
class SupabaseMocks {
SupabaseMocks() {
when(() => client.auth).thenReturn(auth);
}
final client = MockSupabaseClient();
final auth = MockGoTrueClient();
}
/// Standard-Overrides für Tests: feste [AppConfig] und ein Supabase-Mock.
///
/// Zusätzliche [overrides] kommen hinten dran und haben Vorrang.
List<Override> testOverrides({
AppConfig? config,
SupabaseClient? supabase,
List<Override> overrides = const [],
}) => [
appConfigProvider.overrideWithValue(config ?? testAppConfig()),
supabaseClientProvider.overrideWithValue(supabase ?? MockSupabaseClient()),
...overrides,
];

View file

@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shuffle/core/config/app_config_provider.dart';
import 'package:shuffle/core/l10n/l10n.dart';
import 'package:shuffle/core/supabase/supabase_client_provider.dart';
import 'fakes.dart';
import 'pump_app.dart';
import 'router_helpers.dart';
class _L10nProbe extends StatelessWidget {
const _L10nProbe();
@override
Widget build(BuildContext context) =>
Scaffold(body: Text(context.l10n.homePlaceholderMessage));
}
void main() {
group('pumpApp', () {
testWidgets('renders German texts by default', (tester) async {
await pumpApp(tester, home: const _L10nProbe());
expect(find.textContaining('Willkommen bei Shuffle'), findsOneWidget);
});
testWidgets('renders English texts for Locale en', (tester) async {
await pumpApp(
tester,
home: const _L10nProbe(),
locale: const Locale('en'),
);
expect(find.textContaining('Welcome to Shuffle'), findsOneWidget);
});
testWidgets('falls back to English for unsupported locales', (
tester,
) async {
await pumpApp(
tester,
home: const _L10nProbe(),
locale: const Locale('fr'),
);
expect(find.textContaining('Welcome to Shuffle'), findsOneWidget);
});
testWidgets('applies the text scale factor', (tester) async {
await pumpApp(tester, home: const _L10nProbe(), textScale: 2);
final context = tester.element(find.byType(_L10nProbe));
expect(MediaQuery.textScalerOf(context).scale(10), 20);
});
testWidgets('provides test AppConfig and Supabase mock by default', (
tester,
) async {
final container = await pumpApp(tester, home: const _L10nProbe());
expect(container.read(appConfigProvider).termsVersion, 'test-1');
expect(container.read(supabaseClientProvider), isA<MockSupabaseClient>());
});
testWidgets('accepts a custom GoRouter', (tester) async {
final router = buildTestRouter(
routes: [
GoRoute(path: '/', builder: (_, _) => const _L10nProbe()),
GoRoute(path: '/other', builder: (_, _) => const Text('other')),
],
);
addTearDown(router.dispose);
await pumpApp(tester, router: router);
router.go('/other');
await tester.pumpAndSettle();
expect(currentPath(router), '/other');
expect(find.text('other'), findsOneWidget);
});
});
group('SupabaseMocks', () {
test('client.auth returns the auth mock', () {
final mocks = SupabaseMocks();
when(() => mocks.auth.currentSession).thenReturn(null);
expect(mocks.client.auth.currentSession, isNull);
verify(() => mocks.auth.currentSession).called(1);
});
});
}

View file

@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:shuffle/app.dart';
import 'package:shuffle/core/l10n/app_locale.dart';
import 'package:shuffle/core/theme/app_theme.dart';
import 'package:shuffle/l10n/app_localizations.dart';
import 'fakes.dart';
/// Pumpt ein einzelnes Widget ([home]) oder einen eigenen [router] in eine
/// Test-App mit AppTheme, l10n-Delegates, ProviderScope und fester [locale].
///
/// Genau eines von [home] und [router] ist anzugeben. Ohne [overrides]
/// gelten [testOverrides] (feste AppConfig, Supabase-Mock). Gibt den
/// [ProviderContainer] zurück, um Provider im Test zu lesen.
Future<ProviderContainer> pumpApp(
WidgetTester tester, {
Widget? home,
GoRouter? router,
Locale locale = const Locale('de'),
List<Override>? overrides,
ThemeMode themeMode = ThemeMode.light,
double? textScale,
bool settle = true,
}) async {
assert(
(home == null) != (router == null),
'Entweder home oder router angeben.',
);
final container = ProviderContainer.test(
overrides: overrides ?? testOverrides(),
);
Widget builder(BuildContext context, Widget? child) => textScale == null
? child!
: MediaQuery(
data: MediaQuery.of(context)
.copyWith(textScaler: TextScaler.linear(textScale)),
child: child!,
);
const delegates = <LocalizationsDelegate<dynamic>>[
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
];
final theme = AppTheme.light();
final darkTheme = AppTheme.dark();
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: router != null
? MaterialApp.router(
routerConfig: router,
theme: theme,
darkTheme: darkTheme,
themeMode: themeMode,
locale: locale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: delegates,
localeResolutionCallback: (l, _) => resolveAppLocale(l),
builder: builder,
)
: MaterialApp(
home: home,
theme: theme,
darkTheme: darkTheme,
themeMode: themeMode,
locale: locale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: delegates,
localeResolutionCallback: (l, _) => resolveAppLocale(l),
builder: builder,
),
),
);
if (settle) await tester.pumpAndSettle();
return container;
}
/// Pumpt die echte [ShuffleApp] (inkl. Router) mit der Gerätesprache
/// [deviceLocale]. Die Sprache wird nach dem Test automatisch zurückgesetzt.
Future<ProviderContainer> pumpShuffleApp(
WidgetTester tester, {
Locale deviceLocale = const Locale('en'),
List<Override>? overrides,
}) async {
tester.platformDispatcher.localesTestValue = [deviceLocale];
addTearDown(tester.platformDispatcher.clearLocalesTestValue);
final container = ProviderContainer.test(
overrides: overrides ?? testOverrides(),
);
await tester.pumpWidget(
UncontrolledProviderScope(container: container, child: const ShuffleApp()),
);
await tester.pumpAndSettle();
return container;
}

View file

@ -0,0 +1,45 @@
import 'package:flutter/foundation.dart';
import 'package:go_router/go_router.dart';
import 'package:shuffle/core/routing/redirect_source.dart';
/// Steuerbare [RedirectSource]: `targets` bildet Pfad -> Weiterleitungsziel ab.
/// [notifyChanged] löst eine Neubewertung der Redirects aus.
class FakeRedirectSource implements RedirectSource {
FakeRedirectSource([Map<String, String>? targets])
: targets = targets ?? <String, String>{};
final Map<String, String> targets;
final _notifier = ValueNotifier<int>(0);
void notifyChanged() => _notifier.value++;
@override
Listenable get changes => _notifier;
@override
String? redirect(GoRouterState state) => targets[state.uri.path];
}
/// Kleiner Test-Router mit eigenen [routes]. Für Screens, die ohne die
/// App-Routen geprüft werden. Der Aufrufer ruft am Ende `dispose` (z. B. per
/// `addTearDown(router.dispose)`).
GoRouter buildTestRouter({
required List<RouteBase> routes,
String initialLocation = '/',
RedirectSource? redirectSource,
}) => GoRouter(
initialLocation: initialLocation,
routes: routes,
refreshListenable: redirectSource?.changes,
redirect: redirectSource == null
? null
: (context, state) => redirectSource.redirect(state),
);
/// Aktueller Pfad des Routers (ohne Query).
String currentPath(GoRouter router) =>
router.routerDelegate.currentConfiguration.uri.path;
/// Aktuelle URI inklusive Query, z. B. für `?from=`-Prüfungen.
Uri currentUri(GoRouter router) =>
router.routerDelegate.currentConfiguration.uri;

20
docs/templates/abdeckungsmatrix.md vendored Normal file
View file

@ -0,0 +1,20 @@
# Abdeckungsmatrix – #<Issue-Nr> <Titel>
Pflicht in der PR-Beschreibung. Pro Akzeptanzkriterium mindestens eine Ebene belegen;
nicht zutreffende Ebenen mit „–“ markieren, nicht automatisierbare Punkte unten begründen.
Testnamen tragen die Referenz („KTO-01 Kriterium n“), siehe `app/test/README.md`.
| AK | Unit | Widget | Integration/E2E (Playwright) | RLS/pgTAP | Status |
|---|---|---|---|---|---|
| KTO-01 Kriterium 1 | `test/features/…/x_test.dart` › Name | `…` | `e2e/…` | `supabase/tests/database/…` | grün |
| KTO-01 Kriterium 2 | – | – | – | – | manuell (Begründung) |
## Nicht automatisiert testbar
- <Punkt, Begründung, wer prüft wann>
## Ausführung
- [ ] `cd app && dart format --set-exit-if-changed . && flutter analyze && flutter test --coverage`
- [ ] `supabase test db` (bei Backend-Änderungen)
- [ ] Abdeckung neue Logik ≥ 80 %, Domänenschicht ≥ 90 %: <Werte>