Testing
Testing Guide
Section titled “Testing Guide”Lumo follows a comprehensive testing strategy covering unit tests, widget tests, and integration tests to ensure reliability and maintainability.
Testing Strategy
Section titled “Testing Strategy”Test Pyramid
Section titled “Test Pyramid” ┌─────────────────┐ │ Integration │ ← End-to-end user flows │ Tests (Few) │ ├─────────────────┤ │ Widget Tests │ ← UI component testing │ (Some) │ ├─────────────────┤ │ Unit Tests │ ← Business logic & utilities │ (Many) │ └─────────────────┘Test Coverage Goals
Section titled “Test Coverage Goals”- Unit Tests: 90%+ coverage for business logic
- Widget Tests: All UI components and user interactions
- Integration Tests: Critical user flows and app states
Test Setup
Section titled “Test Setup”Dependencies
Section titled “Dependencies”dev_dependencies: flutter_test: sdk: flutter mockito: ^5.4.4 build_runner: ^2.4.13 realm_generator: ^20.1.1 integration_test: sdk: flutter patrol: ^3.0.0 # For complex integration testsTest Configuration
Section titled “Test Configuration”import 'package:flutter_test/flutter_test.dart';import 'package:mockito/annotations.dart';import 'package:mockito/mockito.dart';import 'package:realm/realm.dart';
// Generate mocks@GenerateMocks([ OtpRepository, AuthService, SettingsService, BackupService, BiometricService, StorageService,])import 'test_helpers.mocks.dart';
// Test utilitiesclass TestHelpers { static Realm createInMemoryRealm() { final config = Configuration.inMemory([ OtpEntrySchema.schema, SettingsSchema.schema, ]); return Realm(config); }
static OtpEntry createTestOtpEntry({ String? id, String issuer = 'Test Service', String account = 'test@example.com', String secret = 'JBSWY3DPEHPK3PXP', }) { return OtpEntry( id: id ?? 'test-id', issuer: issuer, account: account, secret: secret, algorithm: OtpAlgorithm.sha1, digits: 6, interval: 30, type: OtpType.totp, created: DateTime.now(), ); }
static SettingsEntity createTestSettings() { return const SettingsEntity( themeMode: ThemeMode.system, biometricEnabled: false, autoLockEnabled: true, autoLockTimeout: Duration(minutes: 5), requireAuthForCopy: false, cloudBackupEnabled: false, autoBackupEnabled: false, showAccountIcons: true, hapticFeedbackEnabled: true, defaultSortOrder: SortOrder.alphabetical, analyticsEnabled: false, crashReportingEnabled: true, otpRefreshInterval: Duration(seconds: 1), developerModeEnabled: false, ); }}Unit Tests
Section titled “Unit Tests”Domain Layer Testing
Section titled “Domain Layer Testing”import 'package:flutter_test/flutter_test.dart';import 'package:mockito/mockito.dart';import 'package:dartz/dartz.dart';
import '../../../../test_helpers.dart';
void main() { late AddOtpEntry useCase; late MockOtpRepository mockRepository;
setUp(() { mockRepository = MockOtpRepository(); useCase = AddOtpEntry(mockRepository); });
group('AddOtpEntry', () { const tOtpUri = 'otpauth://totp/Example:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example'; final tOtpEntry = TestHelpers.createTestOtpEntry( issuer: 'Example', account: 'user@example.com', );
test('should add OTP entry when URI is valid and no duplicates exist', () async { // arrange when(mockRepository.getAllOtpEntries()) .thenAnswer((_) async => const Right([])); when(mockRepository.addOtpEntry(any)) .thenAnswer((_) async => const Right(null));
// act final result = await useCase(const AddOtpEntryParams(otpUri: tOtpUri));
// assert expect(result, const Right(null)); verify(mockRepository.getAllOtpEntries()).called(1); verify(mockRepository.addOtpEntry(any)).called(1); });
test('should return ValidationFailure when URI is invalid', () async { // arrange const invalidUri = 'invalid-uri';
// act final result = await useCase(const AddOtpEntryParams(otpUri: invalidUri));
// assert expect(result, const Left(ValidationFailure('Invalid OTP URI format'))); verifyNever(mockRepository.getAllOtpEntries()); verifyNever(mockRepository.addOtpEntry(any)); });
test('should return ValidationFailure when duplicate entry exists', () async { // arrange when(mockRepository.getAllOtpEntries()) .thenAnswer((_) async => Right([tOtpEntry]));
// act final result = await useCase(const AddOtpEntryParams(otpUri: tOtpUri));
// assert expect(result, const Left(ValidationFailure('Account already exists'))); verify(mockRepository.getAllOtpEntries()).called(1); verifyNever(mockRepository.addOtpEntry(any)); });
test('should return DatabaseFailure when repository throws exception', () async { // arrange when(mockRepository.getAllOtpEntries()) .thenAnswer((_) async => const Right([])); when(mockRepository.addOtpEntry(any)) .thenAnswer((_) async => const Left(DatabaseFailure('Database error')));
// act final result = await useCase(const AddOtpEntryParams(otpUri: tOtpUri));
// assert expect(result, const Left(DatabaseFailure('Database error'))); }); });}Utility Testing
Section titled “Utility Testing”import 'package:flutter_test/flutter_test.dart';
void main() { group('OtpUtils', () { group('generateTotp', () { test('should generate correct TOTP for known test vector', () { // Test vector from RFC 6238 const secret = 'JBSWY3DPEHPK3PXP'; const timeStep = 59; // Unix time 59 = time step 1
final otp = OtpUtils.generateTotpAtTime( secret: secret, time: timeStep, algorithm: OtpAlgorithm.sha1, digits: 8, interval: 30, );
expect(otp, '94287082'); });
test('should generate different codes for different time steps', () { const secret = 'JBSWY3DPEHPK3PXP';
final otp1 = OtpUtils.generateTotpAtTime( secret: secret, time: 59, algorithm: OtpAlgorithm.sha1, digits: 6, interval: 30, );
final otp2 = OtpUtils.generateTotpAtTime( secret: secret, time: 1111111109, algorithm: OtpAlgorithm.sha1, digits: 6, interval: 30, );
expect(otp1, isNot(equals(otp2))); }); });
group('generateHotp', () { test('should generate correct HOTP for known test vectors', () { // Test vectors from RFC 4226 const secret = 'JBSWY3DPEHPK3PXP';
final testVectors = { 0: '755224', 1: '287082', 2: '359152', 3: '969429', 4: '338314', };
for (final entry in testVectors.entries) { final otp = OtpUtils.generateHotp( secret: secret, counter: entry.key, algorithm: OtpAlgorithm.sha1, digits: 6, );
expect(otp, entry.value, reason: 'Failed for counter ${entry.key}'); } }); });
group('validateSecret', () { test('should return true for valid base32 secrets', () { const validSecrets = [ 'JBSWY3DPEHPK3PXP', 'ABCDEFGHIJKLMNOP', 'QRSTUVWXYZ234567', ];
for (final secret in validSecrets) { expect(OtpUtils.isValidSecret(secret), isTrue, reason: 'Should be valid: $secret'); } });
test('should return false for invalid base32 secrets', () { const invalidSecrets = [ '', '1234567890', // Contains invalid chars 'INVALID8901', // Contains 8, 9, 0, 1 'hello world', // Contains lowercase and spaces ];
for (final secret in invalidSecrets) { expect(OtpUtils.isValidSecret(secret), isFalse, reason: 'Should be invalid: $secret'); } }); }); });}Data Layer Testing
Section titled “Data Layer Testing”import 'package:flutter_test/flutter_test.dart';import 'package:realm/realm.dart';
import '../../../../test_helpers.dart';
void main() { late OtpRepositoryImpl repository; late Realm realm;
setUp(() { realm = TestHelpers.createInMemoryRealm(); repository = OtpRepositoryImpl(realm); });
tearDown(() { realm.close(); });
group('OtpRepositoryImpl', () { test('should return empty list when no entries exist', () async { // act final result = await repository.getAllOtpEntries();
// assert expect(result.isRight(), isTrue); expect(result.getOrElse(() => []), isEmpty); });
test('should add and retrieve OTP entry', () async { // arrange final entry = TestHelpers.createTestOtpEntry();
// act final addResult = await repository.addOtpEntry(entry); final getResult = await repository.getAllOtpEntries();
// assert expect(addResult.isRight(), isTrue); expect(getResult.isRight(), isTrue);
final entries = getResult.getOrElse(() => []); expect(entries, hasLength(1)); expect(entries.first.issuer, entry.issuer); expect(entries.first.account, entry.account); });
test('should update existing OTP entry', () async { // arrange final entry = TestHelpers.createTestOtpEntry(); await repository.addOtpEntry(entry);
final updatedEntry = entry.copyWith( issuer: 'Updated Service', lastUsed: DateTime.now(), );
// act final updateResult = await repository.updateOtpEntry(updatedEntry); final getResult = await repository.getAllOtpEntries();
// assert expect(updateResult.isRight(), isTrue); expect(getResult.isRight(), isTrue);
final entries = getResult.getOrElse(() => []); expect(entries, hasLength(1)); expect(entries.first.issuer, 'Updated Service'); expect(entries.first.lastUsed, isNotNull); });
test('should delete OTP entry', () async { // arrange final entry = TestHelpers.createTestOtpEntry(); await repository.addOtpEntry(entry);
// act final deleteResult = await repository.deleteOtpEntry(entry.id); final getResult = await repository.getAllOtpEntries();
// assert expect(deleteResult.isRight(), isTrue); expect(getResult.isRight(), isTrue); expect(getResult.getOrElse(() => []), isEmpty); });
test('should search entries by issuer and account', () async { // arrange final entries = [ TestHelpers.createTestOtpEntry(id: '1', issuer: 'Google', account: 'user@gmail.com'), TestHelpers.createTestOtpEntry(id: '2', issuer: 'Microsoft', account: 'user@outlook.com'), TestHelpers.createTestOtpEntry(id: '3', issuer: 'GitHub', account: 'user@github.com'), ];
for (final entry in entries) { await repository.addOtpEntry(entry); }
// act final result = await repository.searchOtpEntries('google');
// assert expect(result.isRight(), isTrue);
final searchResults = result.getOrElse(() => []); expect(searchResults, hasLength(1)); expect(searchResults.first.issuer, 'Google'); }); });}Widget Tests
Section titled “Widget Tests”Testing UI Components
Section titled “Testing UI Components”import 'package:flutter/material.dart';import 'package:flutter/services.dart';import 'package:flutter_test/flutter_test.dart';import 'package:flutter_riverpod/flutter_riverpod.dart';import 'package:mockito/mockito.dart';
import '../../../../test_helpers.dart';
void main() { group('OtpTile', () { late OtpEntry testEntry;
setUp(() { testEntry = TestHelpers.createTestOtpEntry(); });
testWidgets('displays OTP entry information correctly', (tester) async { await tester.pumpWidget( ProviderScope( child: MaterialApp( home: Scaffold( body: OtpTile( entry: testEntry, onDelete: () {}, onEdit: () {}, ), ), ), ), );
expect(find.text(testEntry.issuer), findsOneWidget); expect(find.text(testEntry.account), findsOneWidget); expect(find.byType(CircularProgressIndicator), findsOneWidget); });
testWidgets('generates and displays OTP code', (tester) async { await tester.pumpWidget( ProviderScope( child: MaterialApp( home: Scaffold( body: OtpTile( entry: testEntry, onDelete: () {}, onEdit: () {}, ), ), ), ), );
// Wait for OTP generation await tester.pump(const Duration(milliseconds: 100));
// Should display 6-digit OTP final otpFinder = find.byWidgetPredicate( (widget) => widget is Text && widget.data != null && RegExp(r'^\d{6}$').hasMatch(widget.data!), );
expect(otpFinder, findsOneWidget); });
testWidgets('copies OTP to clipboard when tapped', (tester) async { // Mock clipboard const mockClipboard = MockMethodChannel(); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(SystemChannels.platform, (call) async { if (call.method == 'Clipboard.setData') { return null; } return null; });
await tester.pumpWidget( ProviderScope( child: MaterialApp( home: Scaffold( body: OtpTile( entry: testEntry, onDelete: () {}, onEdit: () {}, ), ), ), ), );
// Wait for OTP generation await tester.pump(const Duration(milliseconds: 100));
// Tap the tile await tester.tap(find.byType(ListTile)); await tester.pump();
// Should show snackbar expect(find.byType(SnackBar), findsOneWidget); expect(find.text('OTP copied to clipboard'), findsOneWidget); });
testWidgets('shows popup menu with options', (tester) async { var editCalled = false; var deleteCalled = false;
await tester.pumpWidget( ProviderScope( child: MaterialApp( home: Scaffold( body: OtpTile( entry: testEntry, onEdit: () => editCalled = true, onDelete: () => deleteCalled = true, ), ), ), ), );
// Tap popup menu button await tester.tap(find.byType(PopupMenuButton)); await tester.pumpAndSettle();
// Check menu items expect(find.text('Copy'), findsOneWidget); expect(find.text('Edit'), findsOneWidget); expect(find.text('Delete'), findsOneWidget);
// Tap edit await tester.tap(find.text('Edit')); await tester.pumpAndSettle();
expect(editCalled, isTrue); });
testWidgets('updates remaining time for TOTP', (tester) async { await tester.pumpWidget( ProviderScope( child: MaterialApp( home: Scaffold( body: OtpTile( entry: testEntry, onDelete: () {}, onEdit: () {}, ), ), ), ), );
// Wait for initial render await tester.pump(const Duration(milliseconds: 100));
// Find initial remaining time final timeFinder = find.byWidgetPredicate( (widget) => widget is Text && widget.data != null && widget.data!.endsWith('s'), );
expect(timeFinder, findsOneWidget);
// Wait for timer update await tester.pump(const Duration(seconds: 1));
// Time should still be displayed (might be different value) expect(timeFinder, findsOneWidget); }); });}
class MockMethodChannel extends MethodChannel { const MockMethodChannel() : super('test');
@override Future<T?> invokeMethod<T>(String method, [dynamic arguments]) async { return null; }}Testing Pages
Section titled “Testing Pages”import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:flutter_riverpod/flutter_riverpod.dart';import 'package:mockito/mockito.dart';
import '../../../../test_helpers.dart';
void main() { group('OtpListPage', () { testWidgets('displays loading indicator when loading', (tester) async { await tester.pumpWidget( ProviderScope( overrides: [ otpListProvider.overrideWith( () => const AsyncValue.loading<List<OtpEntry>>(), ), ], child: const MaterialApp( home: OtpListPage(), ), ), );
expect(find.byType(CircularProgressIndicator), findsOneWidget); });
testWidgets('displays empty state when no entries', (tester) async { await tester.pumpWidget( ProviderScope( overrides: [ otpListProvider.overrideWith( () => const AsyncValue.data<List<OtpEntry>>([]), ), ], child: const MaterialApp( home: OtpListPage(), ), ), );
expect(find.byType(EmptyOtpListWidget), findsOneWidget); });
testWidgets('displays list of OTP entries', (tester) async { final entries = [ TestHelpers.createTestOtpEntry(id: '1', issuer: 'Google'), TestHelpers.createTestOtpEntry(id: '2', issuer: 'Microsoft'), ];
await tester.pumpWidget( ProviderScope( overrides: [ otpListProvider.overrideWith( () => AsyncValue.data<List<OtpEntry>>(entries), ), ], child: const MaterialApp( home: OtpListPage(), ), ), );
expect(find.byType(OtpTile), findsNWidgets(2)); expect(find.text('Google'), findsOneWidget); expect(find.text('Microsoft'), findsOneWidget); });
testWidgets('navigates to QR scanner when FAB is tapped', (tester) async { await tester.pumpWidget( ProviderScope( overrides: [ otpListProvider.overrideWith( () => const AsyncValue.data<List<OtpEntry>>([]), ), ], child: const MaterialApp( home: OtpListPage(), ), ), );
// Find and tap the scanner button final scannerButton = find.byIcon(Icons.qr_code_scanner); expect(scannerButton, findsOneWidget);
await tester.tap(scannerButton); await tester.pumpAndSettle();
// Should navigate to QR scanner expect(find.byType(QrScannerPage), findsOneWidget); });
testWidgets('displays error state when loading fails', (tester) async { await tester.pumpWidget( ProviderScope( overrides: [ otpListProvider.overrideWith( () => AsyncValue.error<List<OtpEntry>>( 'Database error', StackTrace.current, ), ), ], child: const MaterialApp( home: OtpListPage(), ), ), );
expect(find.text('Error: Database error'), findsOneWidget); expect(find.text('Retry'), findsOneWidget); }); });}Integration Tests
Section titled “Integration Tests”Full App Flow Testing
Section titled “Full App Flow Testing”import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:integration_test/integration_test.dart';import 'package:lumo/main.dart' as app;
void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Lumo App Integration Tests', () { testWidgets('complete OTP workflow', (tester) async { // Start the app app.main(); await tester.pumpAndSettle();
// Should start with authentication if required // For this test, assume we bypass auth or it's disabled
// Should show empty OTP list expect(find.byType(EmptyOtpListWidget), findsOneWidget);
// Tap add button to open QR scanner await tester.tap(find.byIcon(Icons.qr_code_scanner)); await tester.pumpAndSettle();
// Should open QR scanner page expect(find.byType(QrScannerPage), findsOneWidget);
// For testing, we'll simulate manual entry instead of QR scan await tester.tap(find.byIcon(Icons.edit)); await tester.pumpAndSettle();
// Enter OTP URI manually const testUri = 'otpauth://totp/Test:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Test'; await tester.enterText(find.byType(TextFormField), testUri); await tester.tap(find.text('Process')); await tester.pumpAndSettle();
// Should process and add the entry expect(find.text('Account Found!'), findsOneWidget);
// Import the account await tester.tap(find.text('Import Account')); await tester.pumpAndSettle();
// Should return to main page with new entry expect(find.text('Test'), findsOneWidget); expect(find.text('user@example.com'), findsOneWidget);
// Should display OTP code final otpCodeFinder = find.byWidgetPredicate( (widget) => widget is Text && widget.data != null && RegExp(r'^\d{6}$').hasMatch(widget.data!), ); expect(otpCodeFinder, findsOneWidget);
// Test copying OTP await tester.tap(find.byType(ListTile)); await tester.pumpAndSettle();
// Should show copy confirmation expect(find.text('OTP copied to clipboard'), findsOneWidget); });
testWidgets('settings workflow', (tester) async { app.main(); await tester.pumpAndSettle();
// Navigate to settings await tester.tap(find.byIcon(Icons.settings)); await tester.pumpAndSettle();
// Should show settings page expect(find.text('Settings'), findsOneWidget);
// Test theme switching await tester.tap(find.text('Dark')); await tester.pumpAndSettle();
// Should switch to dark theme final materialApp = tester.widget<MaterialApp>(find.byType(MaterialApp)); expect(materialApp.themeMode, ThemeMode.dark);
// Test biometric toggle final biometricSwitch = find.byWidgetPredicate( (widget) => widget is Switch && widget.value == false, // Initially off );
if (biometricSwitch.evaluate().isNotEmpty) { await tester.tap(biometricSwitch); await tester.pumpAndSettle();
// May show biometric setup dialog if (find.text('Set up biometric authentication').evaluate().isNotEmpty) { await tester.tap(find.text('OK')); await tester.pumpAndSettle(); } } });
testWidgets('backup and restore workflow', (tester) async { app.main(); await tester.pumpAndSettle();
// Add a test entry first await _addTestEntry(tester);
// Navigate to backup settings await tester.tap(find.byIcon(Icons.settings)); await tester.pumpAndSettle();
await tester.tap(find.text('Backup & Sync')); await tester.pumpAndSettle();
// Enable cloud backup await tester.tap(find.byType(Switch).first); await tester.pumpAndSettle();
// May require authentication setup if (find.text('Sign in required').evaluate().isNotEmpty) { await tester.tap(find.text('Sign In')); await tester.pumpAndSettle();
// For testing, we'd need to mock the auth flow // This would typically involve Firebase auth }
// Create backup await tester.tap(find.text('Create Backup')); await tester.pumpAndSettle();
// Should show backup progress/success expect(find.textContaining('Backup'), findsWidgets); }); });}
Future<void> _addTestEntry(WidgetTester tester) async { await tester.tap(find.byIcon(Icons.qr_code_scanner)); await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.edit)); await tester.pumpAndSettle();
const testUri = 'otpauth://totp/Test:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Test'; await tester.enterText(find.byType(TextFormField), testUri); await tester.tap(find.text('Process')); await tester.pumpAndSettle();
await tester.tap(find.text('Import Account')); await tester.pumpAndSettle();}Test Automation
Section titled “Test Automation”GitHub Actions Test Pipeline
Section titled “GitHub Actions Test Pipeline”name: Test
on: push: branches: [ main, develop ] pull_request: branches: [ main ]
jobs: test: runs-on: ubuntu-latest
steps: - uses: actions/checkout@v3
- name: Setup Flutter uses: subosito/flutter-action@v2 with: flutter-version: '3.8.1' cache: true
- name: Get dependencies run: flutter pub get
- name: Generate code run: dart run build_runner build
- name: Analyze code run: flutter analyze
- name: Run unit tests run: flutter test --coverage --test-randomize-ordering-seed random
- name: Upload coverage uses: codecov/codecov-action@v3 with: file: coverage/lcov.info
integration_test: runs-on: macos-latest
steps: - uses: actions/checkout@v3
- name: Setup Flutter uses: subosito/flutter-action@v2 with: flutter-version: '3.8.1' cache: true
- name: Get dependencies run: flutter pub get
- name: Run iOS integration tests run: | cd ios xcodebuild -workspace Runner.xcworkspace -scheme Runner -configuration Debug -destination 'platform=iOS Simulator,name=iPhone 14' build cd .. flutter test integration_test/Test Scripts
Section titled “Test Scripts”#!/bin/bashecho "Running Lumo tests..."
# Unit tests with coverageecho "Running unit tests..."flutter test --coverage --test-randomize-ordering-seed random
# Widget testsecho "Running widget tests..."flutter test test/features/*/presentation/widgets/
# Integration testsecho "Running integration tests..."flutter test integration_test/
# Generate coverage reportecho "Generating coverage report..."genhtml coverage/lcov.info -o coverage/html
echo "Tests completed! Coverage report available at coverage/html/index.html"Testing Best Practices
Section titled “Testing Best Practices”Test Organization
Section titled “Test Organization”// Good test structuregroup('OtpRepository', () { group('getAllOtpEntries', () { test('should return empty list when no entries exist', () async { // Test implementation });
test('should return all entries sorted by issuer', () async { // Test implementation }); });
group('addOtpEntry', () { test('should add entry successfully', () async { // Test implementation });
test('should reject duplicate entries', () async { // Test implementation }); });});Mocking Guidelines
Section titled “Mocking Guidelines”// Mock only what you need to controlwhen(mockRepository.getAllOtpEntries()) .thenAnswer((_) async => const Right([]));
// Verify important interactionsverify(mockRepository.addOtpEntry(any)).called(1);
// Don't over-mock - use real objects when possiblefinal realCryptoUtils = CryptoUtils(); // Use real utilityTest Data Management
Section titled “Test Data Management”// Use test helpers for consistent datafinal testEntry = TestHelpers.createTestOtpEntry( issuer: 'Custom Issuer', account: 'test@example.com',);
// Use meaningful test dataconst validOtpUri = 'otpauth://totp/Example:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example';const invalidOtpUri = 'not-an-otp-uri';This comprehensive testing strategy ensures Lumo is reliable, maintainable, and bug-free! 🧪