This guide outlines the testing strategy and best practices for the SparX Wallet Flutter application. It provides concrete examples from our existing codebase to illustrate effective testing approaches.
The project employs a multi-level testing strategy:
- Unit Tests - Test individual components in isolation
- Widget Tests - Test UI components and their behavior
- Integration Tests - Test interactions between multiple components
# Run all tests
melos run test
# Run only dart tests
melos run test:dart
# Run only flutter tests
melos run test:flutter
# Run integration tests
melos run test:integration
# Run a specific test
flutter test test/path/to/test_file.dart -p name="test name"Each test file follows this consistent structure:
void main() {
late ClassBeingTested objectUnderTest;
late MockDependency mockDependency;
setUp(() {
// Initialize mocks and the object under test before each test
mockDependency = MockDependency();
objectUnderTest = ClassBeingTested(mockDependency);
});
group('MethodName', () {
test('should behave in a certain way when given certain input', () {
// Arrange - set up test conditions
// Act - execute the method being tested
// Assert - verify the expected behavior
});
// Additional tests for this method...
});
// Additional groups for other methods...
tearDown(() {
// Clean up after tests if necessary
});
}We use mocktail for mocking dependencies. Mock classes are defined at the top of the test file:
class MockDependency extends Mock implements Dependency {}Each test follows the AAA pattern (explicit comments marking each section (Arrange, Act, Assert) in test files are preferred but not required):
test('returns the latest available version when current version is lower', () {
// Arrange - set up test data
const releaseNotes = ReleaseNotes(
notes: {
'1.0.0': ReleaseNote(available: true, info: 'Version 1.0.0'),
'1.1.0': ReleaseNote(available: true, info: 'Version 1.1.0'),
'1.2.0': ReleaseNote(available: false, info: 'Version 1.2.0'),
},
);
// Act - execute the method being tested
final result = latestVersionFinder.findLatestVersion(
releaseNotes,
'0.9.0',
);
// Assert - verify the expected behavior
expect(result, isNotNull);
expect(result!.key, '1.1.0');
expect(result.value.info, 'Version 1.1.0');
});Use when to define how mocks should behave:
// Basic response
when(() => mockService.getValue()).thenReturn('test value');
// Async response
when(() => mockService.getValueAsync())
.thenAnswer((_) async => 'test value');
// Throwing exceptions
when(() => mockService.getValue()).thenThrow(Exception('error'));
// Different responses for different arguments
when(() => mockService.compute(1)).thenReturn(10);
when(() => mockService.compute(2)).thenReturn(20);Use verify to ensure methods are called with expected arguments:
// Verify a method was called exactly once
verify(() => mockService.getValue()).called(1);
// Verify a method was never called
verifyNever(() => mockService.getValue());
// Verify a method was called with specific arguments
verify(() => mockService.setValue('expected value')).called(1);
// Verify a method was called with any value of a specific type
verify(() => mockService.setValue(any<String>())).called(1);Always test edge cases, not just the happy path:
test('returns null when release notes are null', () {
// Act
final result = latestVersionFinder.findLatestVersion(
null,
'1.0.0',
);
// Assert
expect(result, isNull);
});Widget tests verify that the UI renders correctly and responds to user interactions:
testWidgets('Counter increments when button is tapped', (WidgetTester tester) async {
// Build the widget
await tester.pumpWidget(const MyApp());
// Verify initial state
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the button
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify the counter was incremented
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});Use our custom pumpApp helper from test/helpers/pump_app.dart to set up the widget test environment:
await tester.pumpApp(
const MyWidget(),
// Optional parameters for custom test setup
);For services that interact with external dependencies (APIs, databases, etc.), we follow these practices:
- Mock external dependencies - Don't make real API calls in tests
- Test error handling - Ensure the service handles errors gracefully
- Test caching behavior - Verify data is properly cached and retrieved
Example from update_service_test.dart:
test('should check warning display rules for warning status', () async {
// Arrange
when(() => mockStorageService.warningCount()).thenReturn(null);
when(() => mockStorageService.warningLastTime()).thenReturn(null);
when(() => mockStorageService.versionForUpdate())
.thenReturn(testNewVersion.key);
emulateStatus(UpdateStatus.warning);
// Act
await updateService.initAndWait();
// Assert
verify(
() => mockConfigReader.getConfig(PresetConfigType.updateRules),
).called(1);
verify(
() => mockConfigReader.getConfig(PresetConfigType.releaseNotes),
).called(1);
verifyNever(() => mockStorageService.updateWarningCount(any<int>()));
verifyNever(() => mockStorageService.updateWarningLastTime());
// Verify that an update request was emitted
expectUpdateRequest(UpdateStatus.warning);
});For testing asynchronous code, we use the async/await pattern with thenAnswer:
test('fetches data asynchronously', () async {
// Arrange
when(() => mockRepository.fetchData())
.thenAnswer((_) async => 'test data');
// Act
final result = await service.getData();
// Assert
expect(result, equals('test data'));
verify(() => mockRepository.fetchData()).called(1);
});For testing stream-based components:
test('emits the correct sequence of values', () {
// Arrange
final controller = StreamController<DataState>();
when(() => mockRepository.getData())
.thenAnswer((_) async => 'test data');
// Assert
expectLater(
controller.stream,
emitsInOrder([
isA<LoadingState>(),
isA<LoadedState>(),
]),
);
// Act - trigger the stream after setting up the expectation
controller.add(LoadingState());
controller.add(LoadedState('test data'));
});For Elementary components, test each layer separately:
- Model - Test business logic and data manipulation
- WidgetModel - Test state management and coordination
- Widget - Test UI rendering and interactions
- Test one thing per test - Each test should focus on a single behavior
- Use descriptive test names - Test names should describe the behavior being tested
- Keep tests isolated - Tests should not depend on each other
- Clean up - Reset mocks and state in
setUpandtearDown - Test error cases - Test how your code handles errors and edge cases
- Test state transitions - Verify that state changes correctly
- Don't test external libraries - Focus on testing your own code
- Use test helpers - Create helper functions for common test setup
- Keep tests fast - Tests should run quickly to provide rapid feedback
Below is a simplified version of a test file following our patterns:
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
// Mock dependencies
class MockDependency extends Mock implements Dependency {}
void main() {
late Service service;
late MockDependency mockDependency;
setUp(() {
mockDependency = MockDependency();
service = Service(mockDependency);
});
group('getData', () {
test('returns data successfully when dependency succeeds', () async {
// Arrange
when(() => mockDependency.fetchData())
.thenAnswer((_) async => 'test data');
// Act
final result = await service.getData();
// Assert
expect(result, equals('test data'));
verify(() => mockDependency.fetchData()).called(1);
});
test('throws exception when dependency fails', () async {
// Arrange
when(() => mockDependency.fetchData())
.thenThrow(Exception('network error'));
// Act & Assert
expect(
() => service.getData(),
throwsA(isA<Exception>()),
);
verify(() => mockDependency.fetchData()).called(1);
});
});
}Tests are automatically run in CI using GitHub Actions to ensure code quality:
- Pull Requests - All tests must pass before merging
- Main Branch - Tests run on each commit to the main branch
- Test Coverage - Coverage reports are generated to track test coverage
For more information, see the workflow configuration in .github/workflows/main.yaml.