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:
parent
936ca7bd4b
commit
adf61b3ba2
8 changed files with 369 additions and 46 deletions
44
app/test/README.md
Normal file
44
app/test/README.md
Normal 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`.
|
||||
|
|
@ -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<void> _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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<int>(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(
|
||||
final source = FakeRedirectSource({'/blocked': '/'});
|
||||
final container = await pumpShuffleApp(
|
||||
tester,
|
||||
overrides: testOverrides(
|
||||
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();
|
||||
|
||||
expect(find.byKey(const Key('home_screen')), findsOneWidget);
|
||||
expect(currentPath(router), '/');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
55
app/test/helpers/fakes.dart
Normal file
55
app/test/helpers/fakes.dart
Normal 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,
|
||||
];
|
||||
88
app/test/helpers/helpers_test.dart
Normal file
88
app/test/helpers/helpers_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
103
app/test/helpers/pump_app.dart
Normal file
103
app/test/helpers/pump_app.dart
Normal 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;
|
||||
}
|
||||
45
app/test/helpers/router_helpers.dart
Normal file
45
app/test/helpers/router_helpers.dart
Normal 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
20
docs/templates/abdeckungsmatrix.md
vendored
Normal 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>
|
||||
Loading…
Reference in a new issue