Skip to main content

Testing Guide

Overview

Comprehensive testing strategy for the AI-Powered Photo Journaling Flutter app covering unit tests, widget tests, integration tests, and manual testing procedures.

Testing Philosophy

  • Test early, test often: Write tests alongside features
  • Pyramid approach: More unit tests, fewer integration tests
  • Test behavior, not implementation: Focus on what, not how
  • Maintainable tests: Keep tests simple and clear

Test Coverage Goals

  • Unit Tests: 80%+ coverage for business logic
  • Widget Tests: 70%+ coverage for UI components
  • Integration Tests: Critical user flows covered
  • Manual Tests: All features tested on real devices

Running Tests

All Tests

# Run all tests
flutter test

# Run with coverage
flutter test --coverage

# Generate HTML coverage report
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html

Specific Tests

# Run single test file
flutter test test/features/entry/entry_bloc_test.dart

# Run tests matching pattern
flutter test --name="EntryBloc"

# Run in watch mode (auto-rerun on changes)
flutter test --watch

Device Testing

# Run on connected iOS device
flutter test integration_test/app_test.dart

# Run with verbose logging
flutter test --verbose

Unit Testing

Testing Business Logic

File: test/features/entry/entry_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:app/features/entry/data/services/entry_service.dart';

void main() {
  group('EntryService', () {
    late EntryService service;

    setUp(() {
      service = EntryService();
    });

    tearDown(() {
      // Clean up
    });

    test('should create entry with valid data', () async {
      // Arrange
      final entry = JournalEntry(
        title: 'Test Entry',
        content: 'Test content',
      );

      // Act
      final result = await service.createEntry(entry);

      // Assert
      expect(result.id, isNotEmpty);
      expect(result.title, equals('Test Entry'));
    });

    test('should throw ValidationException with empty title', () async {
      // Arrange
      final entry = JournalEntry(
        title: '',
        content: 'Test content',
      );

      // Act & Assert
      expect(
        () => service.createEntry(entry),
        throwsA(isA<ValidationException>()),
      );
    });
  });
}

Testing Utilities

File: test/core/utils/date_utils_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:app/core/utils/date_utils.dart';

void main() {
  group('DateUtils', () {
    test('should format date correctly', () {
      final date = DateTime(2025, 11, 15);
      final formatted = DateUtils.formatDate(date);
      expect(formatted, equals('Nov 15, 2025'));
    });

    test('should calculate days ago', () {
      final pastDate = DateTime.now().subtract(Duration(days: 5));
      final daysAgo = DateUtils.daysAgo(pastDate);
      expect(daysAgo, equals(5));
    });
  });
}

Widget Testing

Testing UI Components

File: test/shared/widgets/buttons/primary_button_test.dart
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:app/shared/widgets/buttons/primary_button.dart';

void main() {
  testWidgets('PrimaryButton shows label', (WidgetTester tester) async {
    // Build widget
    await tester.pumpWidget(
      CupertinoApp(
        home: PrimaryButton(
          label: 'Test Button',
          onPressed: () {},
        ),
      ),
    );

    // Verify
    expect(find.text('Test Button'), findsOneWidget);
  });

  testWidgets('PrimaryButton calls onPressed', (WidgetTester tester) async {
    var pressed = false;

    await tester.pumpWidget(
      CupertinoApp(
        home: PrimaryButton(
          label: 'Test',
          onPressed: () => pressed = true,
        ),
      ),
    );

    // Tap button
    await tester.tap(find.text('Test'));
    await tester.pump();

    // Verify callback was called
    expect(pressed, isTrue);
  });

  testWidgets('PrimaryButton is disabled when onPressed is null',
      (WidgetTester tester) async {
    await tester.pumpWidget(
      const CupertinoApp(
        home: PrimaryButton(
          label: 'Test',
          onPressed: null,
        ),
      ),
    );

    // Find button
    final button = tester.widget<CupertinoButton>(
      find.byType(CupertinoButton),
    );

    // Verify disabled
    expect(button.enabled, isFalse);
  });
}

Testing Screens

File: test/features/timeline/presentation/screens/timeline_screen_test.dart
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:app/features/timeline/presentation/screens/timeline_screen.dart';
import 'package:app/features/timeline/presentation/bloc/timeline_bloc.dart';

void main() {
  group('TimelineScreen', () {
    late TimelineBloc bloc;

    setUp(() {
      bloc = TimelineBloc();
    });

    tearDown(() {
      bloc.close();
    });

    testWidgets('shows loading indicator initially',
        (WidgetTester tester) async {
      await tester.pumpWidget(
        CupertinoApp(
          home: BlocProvider.value(
            value: bloc,
            child: const TimelineScreen(),
          ),
        ),
      );

      expect(find.byType(CupertinoActivityIndicator), findsOneWidget);
    });

    testWidgets('shows empty state when no entries',
        (WidgetTester tester) async {
      // Emit empty state
      bloc.add(const TimelineLoadRequested());
      await tester.pumpWidget(
        CupertinoApp(
          home: BlocProvider.value(
            value: bloc,
            child: const TimelineScreen(),
          ),
        ),
      );
      await tester.pumpAndSettle();

      expect(find.byType(EmptyTimelineWidget), findsOneWidget);
    });
  });
}

BLoC Testing

File: test/features/entry/presentation/bloc/entry_bloc_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:bloc_test/bloc_test.dart';
import 'package:app/features/entry/presentation/bloc/entry_bloc.dart';

void main() {
  group('EntryBloc', () {
    late EntryBloc bloc;

    setUp(() {
      bloc = EntryBloc();
    });

    tearDown(() {
      bloc.close();
    });

    test('initial state is EntryInitial', () {
      expect(bloc.state, equals(EntryInitial()));
    });

    blocTest<EntryBloc, EntryState>(
      'emits [EntryLoading, EntryLoaded] when CreateEntryRequested is added',
      build: () => bloc,
      act: (bloc) => bloc.add(CreateEntryRequested(
        title: 'Test',
        content: 'Test content',
      )),
      expect: () => [
        isA<EntryLoading>(),
        isA<EntryLoaded>(),
      ],
    );

    blocTest<EntryBloc, EntryState>(
      'emits [EntryLoading, EntryError] when creation fails',
      build: () => bloc,
      act: (bloc) => bloc.add(CreateEntryRequested(
        title: '', // Invalid
        content: 'Test',
      )),
      expect: () => [
        isA<EntryLoading>(),
        isA<EntryError>(),
      ],
    );
  });
}

Integration Testing

Setup

Create integration_test/app_test.dart:
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:app/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('App Integration Tests', () {
    testWidgets('complete journal entry creation flow',
        (WidgetTester tester) async {
      // Start app
      app.main();
      await tester.pumpAndSettle();

      // Navigate to create entry
      await tester.tap(find.byIcon(CupertinoIcons.add));
      await tester.pumpAndSettle();

      // Capture photo (mock)
      await tester.tap(find.text('Capture Photo'));
      await tester.pumpAndSettle();

      // Wait for AI prompt generation
      await tester.pump(const Duration(seconds: 5));
      await tester.pumpAndSettle();

      // Select prompt
      await tester.tap(find.text('Select this prompt'));
      await tester.pumpAndSettle();

      // Add journal text
      await tester.enterText(
        find.byType(CupertinoTextField),
        'Test journal entry content',
      );

      // Save entry
      await tester.tap(find.text('Save'));
      await tester.pumpAndSettle();

      // Verify entry appears in timeline
      expect(find.text('Test journal entry content'), findsOneWidget);
    });
  });
}

Running Integration Tests

# Run integration tests
flutter test integration_test/app_test.dart

# Run on device
flutter test integration_test/app_test.dart --device-id=<device-id>

Manual Testing Checklist

Pre-Release Testing

Functional Testing

  • Entry Creation
    • Capture photo from camera
    • Select photo from library
    • AI prompt generation works
    • Can select and edit prompt
    • Can add custom emotions
    • Entry saves successfully
  • Timeline
    • Entries display correctly
    • Infinite scroll works
    • Pull-to-refresh works
    • Tap to view detail works
    • Photos load correctly
  • Search
    • Text search works
    • Emotion filter works
    • Date range filter works
    • Results display correctly
  • Calendar
    • Calendar displays correctly
    • Can navigate months
    • Entry indicators show
    • Tap date to filter works
  • Settings
    • Profile updates save
    • Notification settings work
    • Privacy settings work
    • Data export works

Accessibility Testing

  • VoiceOver
    • All buttons have labels
    • Images have alt text
    • Navigation works with VoiceOver
    • Forms are accessible
  • Dynamic Type
    • Text scales correctly
    • Layouts don’t break with large text
    • All text is readable
  • Color Contrast
    • Meets WCAG AA standards
    • Works in dark mode
    • No color-only indicators

Performance Testing

  • App Launch
    • Launches in < 2 seconds
    • No visible lag
  • Scrolling
    • Timeline scrolls at 60 FPS
    • No dropped frames
    • Smooth animations
  • Memory
    • No memory leaks
    • Memory usage stable
    • No crashes after extended use
  • Network
    • Works on WiFi
    • Works on cellular
    • Handles offline gracefully

Device Testing

Test on multiple devices:
  • iPhone 12 (iOS 15)
  • iPhone 14 Pro (iOS 16)
  • iPhone 15 Pro Max (iOS 17)
  • iPad (latest iOS)

Edge Cases

  • Empty States
    • No entries yet
    • No search results
    • No notifications
  • Error States
    • Network error
    • API error
    • Invalid input
    • Permission denied
  • Boundary Conditions
    • Very long journal entry (10,000 characters)
    • Many entries (1,000+)
    • Large photo (25MB)
    • Very old date
    • Future date

Mocking

Mock Services

Create mocks for external dependencies:
// test/mocks/mock_entry_service.dart
import 'package:mockito/annotations.dart';
import 'package:app/features/entry/data/services/entry_service.dart';

@GenerateMocks([EntryService])
void main() {}
Generate mocks:
flutter pub run build_runner build
Use in tests:
import 'package:mockito/mockito.dart';
import 'mocks/mock_entry_service.mocks.dart';

void main() {
  test('should use mocked service', () {
    final mockService = MockEntryService();
    when(mockService.getEntries()).thenAnswer(
      (_) async => [/* mock data */],
    );

    // Use mockService in test
  });
}

Golden Tests

Test visual appearance:
testWidgets('EntryCard matches golden', (WidgetTester tester) async {
  await tester.pumpWidget(
    CupertinoApp(
      home: EntryCard(entry: mockEntry),
    ),
  );

  await expectLater(
    find.byType(EntryCard),
    matchesGoldenFile('goldens/entry_card.png'),
  );
});
Update goldens:
flutter test --update-goldens

CI/CD Integration

GitHub Actions (.github/workflows/test.yml):
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v3

      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.16.0'

      - name: Install dependencies
        run: flutter pub get

      - name: Run analyzer
        run: flutter analyze

      - name: Run tests
        run: flutter test --coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info

Best Practices

  1. Test Naming: Use descriptive names
    • should create entry with valid data
    • test1
  2. Arrange-Act-Assert: Structure tests clearly
    // Arrange: Setup
    final input = 'test';
    
    // Act: Execute
    final result = function(input);
    
    // Assert: Verify
    expect(result, equals('expected'));
    
  3. One Assertion Per Test: Keep tests focused
    • Each test should verify one behavior
    • Use multiple tests for multiple behaviors
  4. Independent Tests: Tests should not depend on each other
    • Each test should setup its own state
    • Use setUp() and tearDown()
  5. Readable Tests: Write tests that document behavior
    • Test names explain what is being tested
    • Code is clean and easy to understand

Troubleshooting

Common Issues

Issue: Tests fail on CI but pass locally
  • Solution: Ensure consistent Flutter version, check timezone dependencies
Issue: Widget tests fail with “No Material/Cupertino widget found”
  • Solution: Wrap widget in CupertinoApp or MaterialApp
Issue: Async tests timeout
  • Solution: Increase timeout, check for infinite loops
Issue: Flaky tests (sometimes pass, sometimes fail)
  • Solution: Add proper waits with pumpAndSettle(), check for race conditions

Last Updated: 2025-11-15 Maintained By: QA Engineer / Frontend Developer