Skip to content

Testing

Lumo follows a comprehensive testing strategy covering unit tests, widget tests, and integration tests to ensure reliability and maintainability.

┌─────────────────┐
│ Integration │ ← End-to-end user flows
│ Tests (Few) │
├─────────────────┤
│ Widget Tests │ ← UI component testing
│ (Some) │
├─────────────────┤
│ Unit Tests │ ← Business logic & utilities
│ (Many) │
└─────────────────┘
  • Unit Tests: 90%+ coverage for business logic
  • Widget Tests: All UI components and user interactions
  • Integration Tests: Critical user flows and app states
pubspec.yaml
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 tests
test/test_helpers.dart
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 utilities
class 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,
);
}
}
test/features/otp/domain/usecases/add_otp_entry_test.dart
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')));
});
});
}
test/core/utils/otp_utils_test.dart
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');
}
});
});
});
}
test/features/otp/data/repositories/otp_repository_impl_test.dart
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');
});
});
}
test/features/otp/presentation/widgets/otp_tile_test.dart
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;
}
}
test/features/otp/presentation/pages/otp_list_page_test.dart
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_test/app_test.dart
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();
}
.github/workflows/test.yml
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/
scripts/run_tests.sh
#!/bin/bash
echo "Running Lumo tests..."
# Unit tests with coverage
echo "Running unit tests..."
flutter test --coverage --test-randomize-ordering-seed random
# Widget tests
echo "Running widget tests..."
flutter test test/features/*/presentation/widgets/
# Integration tests
echo "Running integration tests..."
flutter test integration_test/
# Generate coverage report
echo "Generating coverage report..."
genhtml coverage/lcov.info -o coverage/html
echo "Tests completed! Coverage report available at coverage/html/index.html"
// Good test structure
group('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
});
});
});
// Mock only what you need to control
when(mockRepository.getAllOtpEntries())
.thenAnswer((_) async => const Right([]));
// Verify important interactions
verify(mockRepository.addOtpEntry(any)).called(1);
// Don't over-mock - use real objects when possible
final realCryptoUtils = CryptoUtils(); // Use real utility
// Use test helpers for consistent data
final testEntry = TestHelpers.createTestOtpEntry(
issuer: 'Custom Issuer',
account: 'test@example.com',
);
// Use meaningful test data
const 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! 🧪