Skip to content

Testing Guide

This comprehensive guide covers testing strategies for Flutter Compositions applications, including unit testing composables, widget testing, integration testing, and mocking dependencies.

Table of Contents

  1. Testing Composables
  2. Widget Testing
  3. Mocking Dependencies
  4. Testing Async Operations
  5. Testing Patterns
  6. Best Practices

Testing Composables

Composables can be tested independently from widgets, making them easy to test in isolation.

Basic Composable Tests

dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_compositions/flutter_compositions.dart';

void main() {
  group('useCounter', () {
    test('should start with initial value', () {
      final (count, increment) = useCounter(initialValue: 5);

      expect(count.value, 5);
    });

    test('should increment count', () {
      final (count, increment) = useCounter(initialValue: 0);

      increment();
      expect(count.value, 1);

      increment();
      expect(count.value, 2);
    });

    test('should decrement count', () {
      final (count, increment, decrement) = useCounter(initialValue: 10);

      decrement();
      expect(count.value, 9);
    });
  });
}

// Example composable
(Ref<int>, void Function(), void Function()) useCounter({int initialValue = 0}) {
  final count = ref(initialValue);

  void increment() => count.value++;
  void decrement() => count.value--;

  return (count, increment, decrement);
}

Testing Composables with Watch

Test that watchers trigger correctly:

dart
test('should call watch callback when value changes', () {
  final values = <int>[];
  final count = ref(0);

  watch(() => count.value, (newValue, oldValue) {
    values.add(newValue);
  });

  count.value = 1;
  count.value = 2;
  count.value = 3;

  // Wait for microtasks to complete
  await Future.delayed(Duration.zero);

  expect(values, [1, 2, 3]);
});

Testing Computed Values

dart
test('should recompute when dependencies change', () {
  final firstName = ref('John');
  final lastName = ref('Doe');

  final fullName = computed(() => '${firstName.value} ${lastName.value}');

  expect(fullName.value, 'John Doe');

  firstName.value = 'Jane';
  expect(fullName.value, 'Jane Doe');

  lastName.value = 'Smith';
  expect(fullName.value, 'Jane Smith');
});

Widget Testing

Testing CompositionWidgets

Use Flutter's standard widget testing tools:

dart
testWidgets('Counter increments when button pressed', (tester) async {
  await tester.pumpWidget(
    MaterialApp(home: CounterPage()),
  );

  // Verify initial state
  expect(find.text('0'), findsOneWidget);
  expect(find.text('1'), findsNothing);

  // Tap increment button
  await tester.tap(find.byIcon(Icons.add));
  await tester.pump();

  // Verify updated state
  expect(find.text('0'), findsNothing);
  expect(find.text('1'), findsOneWidget);
});

Testing Reactive Props

Test that widgets respond to prop changes:

dart
testWidgets('Widget updates when props change', (tester) async {
  Future<void> buildWidget(String name) async {
    await tester.pumpWidget(
      MaterialApp(
        home: UserGreeting(username: name),
      ),
    );
  }

  await buildWidget('Alice');
  expect(find.text('Hello, Alice!'), findsOneWidget);

  await buildWidget('Bob');
  await tester.pump();
  expect(find.text('Hello, Bob!'), findsOneWidget);
});

class UserGreeting extends CompositionWidget {
  final String username;
  const UserGreeting({required this.username});

  @override
  Widget Function(BuildContext) setup() {
    final props = widget();
    final greeting = computed(() => 'Hello, ${props.value.username}!');

    return (context) => Text(greeting.value);
  }
}

Testing with CompositionBuilder

Use CompositionBuilder for testing without creating widget classes:

dart
testWidgets('CompositionBuilder test', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      home: CompositionBuilder(
        setup: () {
          final count = ref(0);

          return (context) => Scaffold(
            body: Center(child: Text('${count.value}')),
            floatingActionButton: FloatingActionButton(
              onPressed: () => count.value++,
              child: Icon(Icons.add),
            ),
          );
        },
      ),
    ),
  );

  expect(find.text('0'), findsOneWidget);

  await tester.tap(find.byType(FloatingActionButton));
  await tester.pump();

  expect(find.text('1'), findsOneWidget);
});

Mocking Dependencies

Creating Mock Services

Use packages like mockito or mocktail to create mocks:

dart
import 'package:mocktail/mocktail.dart';

class MockAuthService extends Mock implements AuthService {}
class MockApiService extends Mock implements ApiService {}

testWidgets('LoginPage with mocked auth service', (tester) async {
  final mockAuth = MockAuthService();

  when(() => mockAuth.login(any(), any())).thenAnswer(
    (_) async => User(id: '1', name: 'Test User'),
  );

  await tester.pumpWidget(
    CompositionBuilder(
      setup: () {
        provide(mockAuth);

        return (context) => MaterialApp(home: LoginPage());
      },
    ),
  );

  // Enter credentials
  await tester.enterText(find.byType(TextField).first, 'test@example.com');
  await tester.enterText(find.byType(TextField).last, 'password');

  // Tap login
  await tester.tap(find.text('Login'));
  await tester.pumpAndSettle();

  // Verify login was called
  verify(() => mockAuth.login('test@example.com', 'password')).called(1);
});

Providing Mock Dependencies

Inject mocks using provide:

dart
testWidgets('Widget uses injected service', (tester) async {
  final mockUserRepo = MockUserRepository();

  when(() => mockUserRepo.getUser('1')).thenAnswer(
    (_) async => User(id: '1', name: 'Alice'),
  );

  await tester.pumpWidget(
    CompositionBuilder(
      setup: () {
        provide<UserRepository>(mockUserRepo);

        return (context) => MaterialApp(
          home: UserProfile(userId: '1'),
        );
      },
    ),
  );

  await tester.pumpAndSettle();

  expect(find.text('Alice'), findsOneWidget);
  verify(() => mockUserRepo.getUser('1')).called(1);
});

Testing with InjectionKeys

Use InjectionKeys for type-safe mocking:

dart
class UserRepositoryKey extends InjectionKey<UserRepository> {
  const UserRepositoryKey();
}

const userRepositoryKey = UserRepositoryKey();

testWidgets('Widget uses keyed dependency', (tester) async {
  final mockRepo = MockUserRepository();

  await tester.pumpWidget(
    CompositionBuilder(
      setup: () {
        provide<UserRepository>(mockRepo, key: userRepositoryKey);

        return (context) => MaterialApp(home: MyWidget());
      },
    ),
  );

  // Test continues...
});

Testing Async Operations

Testing useFuture

dart
testWidgets('useFuture handles loading and success', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      home: CompositionBuilder(
        setup: () {
          final userData = useFuture(() async {
            await Future.delayed(Duration(milliseconds: 100));
            return User(name: 'Alice');
          });

          return (context) => switch (userData.value) {
            AsyncLoading() => CircularProgressIndicator(),
            AsyncData(:final value) => Text(value.name),
            AsyncError(:final errorValue) => Text('Error: $errorValue'),
            AsyncIdle() => SizedBox.shrink(),
          };
        },
      ),
    ),
  );

  // Initially loading
  expect(find.byType(CircularProgressIndicator), findsOneWidget);

  // Wait for future to complete
  await tester.pumpAndSettle();

  // Shows data
  expect(find.text('Alice'), findsOneWidget);
  expect(find.byType(CircularProgressIndicator), findsNothing);
});

Testing useAsyncData

dart
testWidgets('useAsyncData with watch refetches', (tester) async {
  final mockApi = MockApiService();
  const apiKey = InjectionKey<MockApiService>('mockApi');

  when(() => mockApi.fetchUser(1)).thenAnswer(
    (_) async => User(id: '1', name: 'Alice'),
  );
  when(() => mockApi.fetchUser(2)).thenAnswer(
    (_) async => User(id: '2', name: 'Bob'),
  );

  await tester.pumpWidget(
    CompositionBuilder(
      setup: () {
        provide(apiKey, mockApi);

        final userId = ref(1);
        final api = inject(apiKey);

        final (status, refresh) = useAsyncData<User, int>(
          (id) => api.fetchUser(id),
          watch: () => userId.value,
        );

        return (context) => Scaffold(
          body: switch (status.value) {
            AsyncData(:final value) => Text(value.name),
            _ => CircularProgressIndicator(),
          },
          floatingActionButton: FloatingActionButton(
            onPressed: () => userId.value = 2,
            child: Icon(Icons.next_plan),
          ),
        );
      },
    ),
  );

  await tester.pumpAndSettle();
  expect(find.text('Alice'), findsOneWidget);

  // Change userId - should refetch
  await tester.tap(find.byType(FloatingActionButton));
  await tester.pump();

  // Loading state
  expect(find.byType(CircularProgressIndicator), findsOneWidget);

  await tester.pumpAndSettle();

  // New data
  expect(find.text('Bob'), findsOneWidget);
  verify(() => mockApi.fetchUser(1)).called(1);
  verify(() => mockApi.fetchUser(2)).called(1);
});

Testing Error States

dart
testWidgets('Shows error state on failure', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      home: CompositionBuilder(
        setup: () {
          final userData = useFuture(() async {
            await Future.delayed(Duration(milliseconds: 100));
            throw Exception('Network error');
          });

          return (context) => switch (userData.value) {
            AsyncError(:final errorValue) => Text('Error: $errorValue'),
            AsyncLoading() => CircularProgressIndicator(),
            _ => SizedBox.shrink(),
          };
        },
      ),
    ),
  );

  await tester.pumpAndSettle();

  expect(find.textContaining('Network error'), findsOneWidget);
});

Testing Patterns

Testing Form Validation

dart
testWidgets('Form validates input', (tester) async {
  await tester.pumpWidget(
    MaterialApp(home: LoginForm()),
  );

  // Submit with empty fields
  await tester.tap(find.text('Submit'));
  await tester.pump();

  expect(find.text('Email is required'), findsOneWidget);
  expect(find.text('Password is required'), findsOneWidget);

  // Enter invalid email
  await tester.enterText(find.byKey(Key('email')), 'invalid');
  await tester.tap(find.text('Submit'));
  await tester.pump();

  expect(find.text('Invalid email'), findsOneWidget);

  // Enter valid data
  await tester.enterText(find.byKey(Key('email')), 'test@example.com');
  await tester.enterText(find.byKey(Key('password')), 'password123');
  await tester.tap(find.text('Submit'));
  await tester.pump();

  // No errors
  expect(find.text('Email is required'), findsNothing);
  expect(find.text('Invalid email'), findsNothing);
});

Testing Lifecycle Hooks

dart
testWidgets('onMounted callback executes', (tester) async {
  var mountedCalled = false;

  await tester.pumpWidget(
    MaterialApp(
      home: CompositionBuilder(
        setup: () {
          onMounted(() {
            mountedCalled = true;
          });

          return (context) => Container();
        },
      ),
    ),
  );

  await tester.pumpAndSettle();

  expect(mountedCalled, true);
});

testWidgets('onUnmounted callback executes', (tester) async {
  var unmountedCalled = false;

  await tester.pumpWidget(
    MaterialApp(
      home: CompositionBuilder(
        setup: () {
          onUnmounted(() {
            unmountedCalled = true;
          });

          return (context) => Container();
        },
      ),
    ),
  );

  // Remove widget
  await tester.pumpWidget(SizedBox.shrink());
  await tester.pump();

  expect(unmountedCalled, true);
});

Testing Controllers

dart
testWidgets('ScrollController tracks position', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      home: CompositionBuilder(
        setup: () {
          final scrollController = useScrollController();
          final offset = ref(0.0);

          watchEffect(() {
            offset.value = scrollController.value.offset;
          });

          return (context) => Column(
            children: [
              Text('Offset: ${offset.value.toInt()}'),
              Expanded(
                child: ListView.builder(
                  controller: scrollController.value,
                  itemCount: 100,
                  itemBuilder: (context, index) => ListTile(
                    title: Text('Item $index'),
                  ),
                ),
              ),
            ],
          );
        },
      ),
    ),
  );

  expect(find.text('Offset: 0'), findsOneWidget);

  // Scroll down
  await tester.drag(find.byType(ListView), Offset(0, -500));
  await tester.pumpAndSettle();

  // Offset should have changed
  expect(find.textContaining('Offset: 0'), findsNothing);
});

Best Practices

1. Test Behavior, Not Implementation

dart
// ✅ Good - Tests observable behavior
testWidgets('Shows user name after loading', (tester) async {
  // Setup and assertions
});

// ❌ Bad - Tests internal implementation
testWidgets('Creates ref with null initial value', (tester) async {
  // Don't test internal details
});

2. Use Descriptive Test Names

dart
// ✅ Good - Clear what is being tested
test('should increment counter when increment is called')
test('should show error message when login fails')
test('should disable submit button when form is invalid')

// ❌ Bad - Unclear purpose
test('test1')
test('button test')
test('works correctly')

3. Arrange-Act-Assert Pattern

dart
test('should add item to cart', () {
  // Arrange
  final cart = CartStore();
  final product = Product(id: '1', name: 'Test', price: 10.0);

  // Act
  cart.addItem(product);

  // Assert
  expect(cart.items.value.length, 1);
  expect(cart.total.value, 10.0);
});

4. Mock External Dependencies

dart
// ✅ Good - Mocks API calls
testWidgets('Shows loading then data', (tester) async {
  final mockApi = MockApiService();
  when(() => mockApi.fetchData()).thenAnswer(
    (_) async => Data(value: 'test'),
  );

  // Test with mock
});

// ❌ Bad - Makes real API calls
testWidgets('Fetches real data', (tester) async {
  // Don't make real network calls in tests
});

5. Test Edge Cases

dart
group('useValidation', () {
  test('should handle empty input', () { /* ... */ });
  test('should handle whitespace', () { /* ... */ });
  test('should handle very long input', () { /* ... */ });
  test('should handle special characters', () { /* ... */ });
  test('should handle null values', () { /* ... */ });
});

6. Clean Up After Tests

dart
testWidgets('Cleans up resources', (tester) async {
  final subscription = stream.listen((_) {});

  addTearDown(() {
    subscription.cancel();
  });

  // Test code
});

7. Use Test Groups

dart
void main() {
  group('AuthService', () {
    late AuthService authService;

    setUp(() {
      authService = AuthService();
    });

    group('login', () {
      test('should succeed with valid credentials', () { /* ... */ });
      test('should fail with invalid credentials', () { /* ... */ });
      test('should update authentication state', () { /* ... */ });
    });

    group('logout', () {
      test('should clear user data', () { /* ... */ });
      test('should update authentication state', () { /* ... */ });
    });
  });
}

Testing Checklist

Use this checklist for comprehensive testing:

Unit Tests (Composables)

  • [ ] Test initial state
  • [ ] Test state updates
  • [ ] Test computed values
  • [ ] Test watch callbacks
  • [ ] Test cleanup (onUnmounted)
  • [ ] Test edge cases

Widget Tests

  • [ ] Test initial render
  • [ ] Test user interactions
  • [ ] Test prop changes
  • [ ] Test reactive updates
  • [ ] Test error states
  • [ ] Test loading states

Integration Tests

  • [ ] Test complete user flows
  • [ ] Test navigation
  • [ ] Test data persistence
  • [ ] Test dependency injection
  • [ ] Test async operations

Mocking

  • [ ] Mock external services
  • [ ] Mock API calls
  • [ ] Mock storage
  • [ ] Verify method calls
  • [ ] Test error scenarios

See Also

Released under the MIT License.