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
| Layer | Coverage Target | Tools | Focus |
|---|---|---|---|
| Unit tests | 80%+ | flutter_test | Models, services, controllers, utilities |
| Widget tests | 60%+ | flutter_test | Component rendering, interaction, state |
| Integration tests | Critical paths | integration_test | Auth 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.dartMock 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
| Stage | Trigger | Actions |
|---|---|---|
| Analyze | Every PR + push to main | flutter analyze, dart format check |
| Test | After analyze passes | Unit + widget tests, coverage upload |
| Build Android | Main branch only | Release AAB generation |
| Build iOS | Main branch only | Release IPA generation (unsigned) |
Performance Targets
| Metric | Target | Measurement |
|---|---|---|
| Cold start | < 2 seconds | Time from tap to interactive dashboard |
| Schedule load | < 500ms | Fetch + render class list for active section |
| OCR processing | < 3 seconds | Camera capture to parsed schedule result |
| Sync cycle | < 1 second | Full offline queue flush on reconnect |
| Frame rate | 60 fps | Smooth scrolling and animations |
| App size | < 30 MB | Release build download size |
| Memory | < 150 MB | Peak 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 .