diff --git a/app/test/README.md b/app/test/README.md new file mode 100644 index 0000000..c15e777 --- /dev/null +++ b/app/test/README.md @@ -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`. diff --git a/app/test/app_test.dart b/app/test/app_test.dart index 1dd3a54..02fb568 100644 --- a/app/test/app_test.dart +++ b/app/test/app_test.dart @@ -1,32 +1,20 @@ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:shuffle/app.dart'; -Future _pumpApp(WidgetTester tester) async { - await tester.pumpWidget(const ProviderScope(child: ShuffleApp())); - await tester.pumpAndSettle(); -} +import 'helpers/pump_app.dart'; void main() { - tearDown(() { - TestWidgetsFlutterBinding.instance.platformDispatcher - .clearLocalesTestValue(); - }); - testWidgets('app starts on the placeholder home page (English fallback)', ( tester, ) async { - tester.platformDispatcher.localesTestValue = [const Locale('fr')]; - await _pumpApp(tester); + await pumpShuffleApp(tester, deviceLocale: const Locale('fr')); expect(find.byKey(const Key('home_screen')), findsOneWidget); expect(find.textContaining('Welcome to Shuffle'), findsOneWidget); }); testWidgets('app shows German texts on a German device', (tester) async { - tester.platformDispatcher.localesTestValue = [const Locale('de', 'DE')]; - await _pumpApp(tester); + await pumpShuffleApp(tester, deviceLocale: const Locale('de', 'DE')); expect(find.textContaining('Willkommen bei Shuffle'), findsOneWidget); }); diff --git a/app/test/core/routing/app_router_test.dart b/app/test/core/routing/app_router_test.dart index dc8a025..d04aabc 100644 --- a/app/test/core/routing/app_router_test.dart +++ b/app/test/core/routing/app_router_test.dart @@ -1,35 +1,17 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.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/redirect_source.dart'; -class _FakeRedirectSource implements RedirectSource { - final notifier = ValueNotifier(0); - String? target; - - @override - Listenable get changes => notifier; - - @override - String? redirect(GoRouterState state) => - state.uri.path == '/blocked' ? target : null; -} +import '../../helpers/fakes.dart'; +import '../../helpers/pump_app.dart'; +import '../../helpers/router_helpers.dart'; void main() { testWidgets('unknown path shows the 404 page and leads back home', ( tester, ) async { - final container = ProviderContainer.test(); - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: const ShuffleApp(), - ), - ); + final container = await pumpShuffleApp(tester); container.read(appRouterProvider).go('/does-not-exist'); await tester.pumpAndSettle(); @@ -42,20 +24,18 @@ void main() { }); testWidgets('redirect source decides where a route leads', (tester) async { - final source = _FakeRedirectSource()..target = '/'; - final container = ProviderContainer.test( - overrides: [redirectSourceProvider.overrideWithValue(source)], - ); - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: const ShuffleApp(), + final source = FakeRedirectSource({'/blocked': '/'}); + final container = await pumpShuffleApp( + tester, + overrides: testOverrides( + overrides: [redirectSourceProvider.overrideWithValue(source)], ), ); - container.read(appRouterProvider).go('/blocked'); + final router = container.read(appRouterProvider)..go('/blocked'); await tester.pumpAndSettle(); expect(find.byKey(const Key('home_screen')), findsOneWidget); + expect(currentPath(router), '/'); }); } diff --git a/app/test/helpers/fakes.dart b/app/test/helpers/fakes.dart new file mode 100644 index 0000000..3c33eda --- /dev/null +++ b/app/test/helpers/fakes.dart @@ -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 testOverrides({ + AppConfig? config, + SupabaseClient? supabase, + List overrides = const [], +}) => [ + appConfigProvider.overrideWithValue(config ?? testAppConfig()), + supabaseClientProvider.overrideWithValue(supabase ?? MockSupabaseClient()), + ...overrides, +]; diff --git a/app/test/helpers/helpers_test.dart b/app/test/helpers/helpers_test.dart new file mode 100644 index 0000000..94f3c26 --- /dev/null +++ b/app/test/helpers/helpers_test.dart @@ -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()); + }); + + 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); + }); + }); +} diff --git a/app/test/helpers/pump_app.dart b/app/test/helpers/pump_app.dart new file mode 100644 index 0000000..835b146 --- /dev/null +++ b/app/test/helpers/pump_app.dart @@ -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 pumpApp( + WidgetTester tester, { + Widget? home, + GoRouter? router, + Locale locale = const Locale('de'), + List? 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 = >[ + 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 pumpShuffleApp( + WidgetTester tester, { + Locale deviceLocale = const Locale('en'), + List? 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; +} diff --git a/app/test/helpers/router_helpers.dart b/app/test/helpers/router_helpers.dart new file mode 100644 index 0000000..8dbb821 --- /dev/null +++ b/app/test/helpers/router_helpers.dart @@ -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? targets]) + : targets = targets ?? {}; + + final Map targets; + final _notifier = ValueNotifier(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 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; diff --git a/docs/templates/abdeckungsmatrix.md b/docs/templates/abdeckungsmatrix.md new file mode 100644 index 0000000..d85b6c3 --- /dev/null +++ b/docs/templates/abdeckungsmatrix.md @@ -0,0 +1,20 @@ +# Abdeckungsmatrix – # + +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 + +- + +## 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 %: