MySched

Testing & CI/CD

MySched uses a layered testing strategy with unit, widget, and integration tests. The CI/CD pipeline runs on GitHub Actions with automated checks on every pull request.

Test Strategy

LayerCoverage TargetToolsFocus
Unit tests80%+flutter_testModels, services, controllers, utilities
Widget tests60%+flutter_testComponent rendering, interaction, state
Integration testsCritical pathsintegration_testAuth flow, schedule CRUD, sync, OCR

Directory Structure

test/
├── unit/
│   ├── models/
│   │   ├── class_item_test.dart
│   │   ├── reminder_entry_test.dart
│   │   └── section_test.dart
│   ├── services/
│   │   ├── sync_service_test.dart
│   │   ├── notification_service_test.dart
│   │   └── remote_config_service_test.dart
│   └── controllers/
│       ├── schedule_controller_test.dart
│       └── reminder_controller_test.dart
├── widget/
│   ├── buttons/
│   ├── inputs/
│   ├── layout/
│   └── screens/
│       ├── dashboard_screen_test.dart
│       └── schedule_detail_test.dart
├── integration/
│   ├── auth_flow_test.dart
│   ├── schedule_crud_test.dart
│   └── sync_flow_test.dart
└── mocks/
    ├── mock_supabase_service.dart
    ├── mock_sync_service.dart
    └── mock_notification_service.dart

Mock Services

All external dependencies are mocked for unit and widget tests. Mocks follow a consistent pattern using mockito:

Dart
// test/mocks/mock_supabase_service.dart
import 'package:mockito/annotations.dart';
import 'package:mysched/services/supabase_service.dart';

@GenerateMocks([SupabaseService])
void main() {}
Dart
// Usage in tests
import 'mock_supabase_service.mocks.dart';

void main() {
  late MockSupabaseService mockSupabase;
  late ScheduleController controller;

  setUp(() {
    mockSupabase = MockSupabaseService();
    controller = ScheduleController(supabase: mockSupabase, sync: MockSyncService());
  });

  test('loadClasses sets loading state', () async {
    when(mockSupabase.getClassItems(any))
        .thenAnswer((_) async => [testClassItem]);

    expect(controller.isLoading, false);

    final future = controller.loadClasses('section-1');
    expect(controller.isLoading, true);

    await future;
    expect(controller.isLoading, false);
    expect(controller.classes.length, 1);
  });

  test('loadClasses handles errors', () async {
    when(mockSupabase.getClassItems(any))
        .thenThrow(Exception('Network error'));

    await controller.loadClasses('section-1');

    expect(controller.error, isNotNull);
    expect(controller.classes, isEmpty);
  });
}

Test Fixtures

Reusable test data is centralized in test/fixtures/:

Dart
// test/fixtures/test_data.dart
final testClassItem = ClassItem(
  id: 'test-class-1',
  userId: 'test-user-1',
  name: 'CS 101',
  description: 'Intro to Computer Science',
  instructor: 'Dr. Smith',
  room: 'Room 204',
  section: 'test-section-1',
  dayOfWeek: 1,
  startTime: TimeOfDay(hour: 9, minute: 0),
  endTime: TimeOfDay(hour: 10, minute: 30),
  color: '#007AFF',
  createdAt: DateTime(2024, 1, 15),
  updatedAt: DateTime(2024, 1, 15),
  isDeleted: false,
);

final testSection = Section(
  id: 'test-section-1',
  userId: 'test-user-1',
  name: 'Fall 2024',
  sortOrder: 0,
  isActive: true,
  isDeleted: false,
  createdAt: DateTime(2024, 1, 1),
  updatedAt: DateTime(2024, 1, 1),
);

Widget Testing

Widget tests verify rendering, interaction, and state transitions:

Dart
testWidgets('ClassCard displays class info', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      home: ClassCard(classItem: testClassItem),
    ),
  );

  expect(find.text('CS 101'), findsOneWidget);
  expect(find.text('Dr. Smith'), findsOneWidget);
  expect(find.text('Room 204'), findsOneWidget);
  expect(find.text('9:00 AM - 10:30 AM'), findsOneWidget);
});

testWidgets('ClassCard tap opens detail sheet', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      home: Scaffold(
        body: ClassCard(classItem: testClassItem),
      ),
    ),
  );

  await tester.tap(find.byType(ClassCard));
  await tester.pumpAndSettle();

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

CI/CD Pipeline

The build pipeline runs on GitHub Actions with the following stages:

YAML
# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  analyze:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.x'
      - run: flutter pub get
      - run: flutter analyze --no-fatal-infos
      - run: dart format --set-exit-if-changed .

  test:
    runs-on: ubuntu-latest
    needs: analyze
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
      - run: flutter pub get
      - run: flutter test --coverage
      - uses: codecov/codecov-action@v3
        with:
          file: coverage/lcov.info

  build-android:
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
      - run: flutter build appbundle --release
      - uses: actions/upload-artifact@v4
        with:
          name: android-release
          path: build/app/outputs/bundle/release/

  build-ios:
    runs-on: macos-latest
    needs: test
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
      - run: flutter build ipa --release --no-codesign
      - uses: actions/upload-artifact@v4
        with:
          name: ios-release
          path: build/ios/ipa/

Pipeline Stages

StageTriggerActions
AnalyzeEvery PR + push to mainflutter analyze, dart format check
TestAfter analyze passesUnit + widget tests, coverage upload
Build AndroidMain branch onlyRelease AAB generation
Build iOSMain branch onlyRelease IPA generation (unsigned)

Performance Targets

MetricTargetMeasurement
Cold start< 2 secondsTime from tap to interactive dashboard
Schedule load< 500msFetch + render class list for active section
OCR processing< 3 secondsCamera capture to parsed schedule result
Sync cycle< 1 secondFull offline queue flush on reconnect
Frame rate60 fpsSmooth scrolling and animations
App size< 30 MBRelease build download size
Memory< 150 MBPeak usage during OCR processing

Performance Monitoring

Dart
// Startup timing
class AppStartupTracker {
  static final _stopwatch = Stopwatch();

  static void markStart() => _stopwatch.start();

  static void markInteractive() {
    _stopwatch.stop();
    AnalyticsService().logTiming(
      category: 'startup',
      variable: 'cold_start',
      value: _stopwatch.elapsedMilliseconds,
    );
  }
}

// Frame tracking
void initPerformanceOverlay() {
  SchedulerBinding.instance.addTimingsCallback((timings) {
    for (final timing in timings) {
      if (timing.totalSpan > const Duration(milliseconds: 16)) {
        AnalyticsService().logEvent('dropped_frame', {
          'duration_ms': timing.totalSpan.inMilliseconds,
        });
      }
    }
  });
}

Running Tests Locally

Shell
# Run all tests
flutter test

# Run with coverage
flutter test --coverage

# Run specific test file
flutter test test/unit/models/class_item_test.dart

# Run integration tests (requires emulator/device)
flutter test integration_test/

# Generate coverage report (requires lcov)
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html

# Run analyzer
flutter analyze

# Check formatting
dart format --set-exit-if-changed .