From 2f5506a6d0fadb1dda8bf2d699417048e69b8446 Mon Sep 17 00:00:00 2001 From: Mohammad Zidane Kurnianto Date: Sat, 31 Jan 2026 20:41:32 +0700 Subject: [PATCH 1/4] feat: Add unit testing and widget testing --- test/data/models/book_model_test.dart | 43 +++++++ .../repositories/book_repository_test.dart | 113 ++++++++++++++++++ .../books/book_detail_screen_test.dart | 94 +++++++++++++++ .../screens/explore/explore_screen_test.dart | 29 +++++ 4 files changed, 279 insertions(+) create mode 100644 test/data/models/book_model_test.dart create mode 100644 test/data/repositories/book_repository_test.dart create mode 100644 test/presentation/screens/books/book_detail_screen_test.dart create mode 100644 test/presentation/screens/explore/explore_screen_test.dart diff --git a/test/data/models/book_model_test.dart b/test/data/models/book_model_test.dart new file mode 100644 index 0000000..94af254 --- /dev/null +++ b/test/data/models/book_model_test.dart @@ -0,0 +1,43 @@ +// * Unit Testing 1: Book Model Mapping Test +import 'package:codexia/data/models/book_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Map _baseMap({required dynamic publishedDate}) { + return { + '_id': 'bk-1', + 'title': 'Chronicles of Testing', + 'cover_image': 'https://example.com/cover.png', + 'author': {'name': 'QA Author'}, + 'category': {'name': 'Adventure'}, + 'summary': 'An adventure in unit testing.', + 'details': { + 'price': 'Rp 10.000', + 'total_pages': '420 pages', + 'published_date': publishedDate, + }, + 'publisher': 'Test House', + }; +} + +void main() { + group('BookModel.fromMap Dates', () { + test('natural language to date', () { + final model = BookModel.fromMap( + _baseMap(publishedDate: '29 November 2023'), + ); + + expect(model.publishedDate.year, 2023); + expect(model.publishedDate.month, 11); + expect(model.publishedDate.day, 29); + expect(model.totalPages, 420); + }); + + test('falls back to epoch when published_date is invalid', () { + final model = BookModel.fromMap(_baseMap(publishedDate: 'not a date')); + + expect(model.publishedDate.year, 1970); + expect(model.publishedDate.month, 1); + expect(model.publishedDate.day, 1); + }); + }); +} diff --git a/test/data/repositories/book_repository_test.dart b/test/data/repositories/book_repository_test.dart new file mode 100644 index 0000000..16dcfa3 --- /dev/null +++ b/test/data/repositories/book_repository_test.dart @@ -0,0 +1,113 @@ +// * Unit Testing 2: Book Repository Test +// Mengetes book repository +import 'package:codexia/data/repositories/book_repository.dart'; +import 'package:codexia/data/services/book_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// Create fake book service (tanpa pemanggilan BukuAcak API) +class _FakeBookService extends BookService { + _FakeBookService({this.booksResponse}); + + Map? booksResponse; + Map? detailResponse; + bool throwOnBooks = false; + bool throwOnDetail = false; + String? lastSortKey; + + @override + Future> fetchBooksRaw({ + String? keyword, + String? genre, + int? year, + String sort = 'newest', + int page = 1, + int pageSize = 20, + }) async { + if (throwOnBooks) throw BookServiceException('books failed'); + lastSortKey = sort; + return booksResponse ?? {'books': []}; + } + + @override + Future> fetchBookDetailRaw(String id) async { + if (throwOnDetail) throw BookServiceException('detail failed'); + final data = detailResponse ?? {'_id': id}; + return data; + } + + @override + Future> fetchRandomBookRaw() { + throw UnimplementedError(); + } + + @override + Future> fetchGenres() { + throw UnimplementedError(); + } +} + +// Placeholder book +Map _bookJson({required String id}) { + return { + '_id': id, + 'title': 'The Testing Book', + 'cover_image': 'https://example.com/cover.jpg', + 'author': {'name': 'Jane Tester'}, + 'category': {'name': 'Fiction'}, + 'summary': 'A tale about writing tests.', + 'details': { + 'price': 'Rp 50.000', + 'total_pages': '320 pages', + 'published_date': '2023-11-29', + }, + 'publisher': 'QA Press', + }; +} + +void main() { + group('BookRepository', () { + test('maps fetchBooks response and forwards sort key', () async { + final fakeService = _FakeBookService( + booksResponse: { + 'books': [_bookJson(id: 'book-1')], + }, + ); + final repo = BookRepository(service: fakeService); + + final result = await repo.fetchBooks( + sort: BookSort.titleAZ, + page: 2, + pageSize: 5, + ); + + expect(fakeService.lastSortKey, equals('titleAZ')); + expect(result, hasLength(1)); + final book = result.first; + expect(book.id, 'book-1'); + expect(book.title, 'The Testing Book'); + expect(book.authorName, 'Jane Tester'); + expect(book.category, 'Fiction'); + expect(book.totalPages, 320); + expect(book.publishedDate.year, 2023); + expect(book.publishedDate.month, 11); + expect(book.publishedDate.day, 29); + expect(book.price, 'Rp 50.000'); + }); + + test('wraps service error into BookRepositoryException', () async { + final fakeService = _FakeBookService()..throwOnDetail = true; + final repo = BookRepository(service: fakeService); + + expect( + repo.fetchBookDetail('missing-id'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('detail failed'), + ), + ), + ); + }); + }); +} diff --git a/test/presentation/screens/books/book_detail_screen_test.dart b/test/presentation/screens/books/book_detail_screen_test.dart new file mode 100644 index 0000000..1b4780c --- /dev/null +++ b/test/presentation/screens/books/book_detail_screen_test.dart @@ -0,0 +1,94 @@ +// * Widget Testing 1: Book Detail Screen Integrity and Toggle Test +import 'package:codexia/data/models/book_model.dart'; +import 'package:codexia/data/models/wishlist_item_model.dart'; +import 'package:codexia/presentation/providers/book_detail_provider.dart'; +import 'package:codexia/presentation/providers/wishlist_provider.dart'; +import 'package:codexia/presentation/screens/books/book_detail_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// Wishlist notifier ringan +class _FakeWishlistNotifier extends WishlistNotifier { + _FakeWishlistNotifier({List? initial}) + : _initial = initial ?? []; + + final List _initial; + int toggleCalls = 0; + + @override + Future> build() async => _initial; + + @override + Future toggle(BookModel book) async { + toggleCalls++; + final current = state.value ?? _initial; + final exists = current.any((w) => w.id == book.id); + final updated = exists + ? current.where((w) => w.id != book.id).toList() + : [ + WishlistItemModel( + id: book.id, + title: book.title, + coverUrl: book.coverUrl, + addedAt: DateTime(2024, 1, 1), + ), + ...current, + ]; + state = AsyncData(updated); + } + + @override + Future removeById(String bookId) async { + final current = state.value ?? _initial; + state = AsyncData(current.where((w) => w.id != bookId).toList()); + } +} + +// Placeholder book +BookModel _fakeBook() { + return BookModel( + id: 'bk-99', + title: 'Widget Testing in Action', + coverUrl: 'https://example.com/widget.png', + authorName: 'Dev Tester', + category: 'Education', + summary: 'Learn how to write widget tests effectively.', + price: 'Rp 25.000', + totalPages: 200, + publishedDate: DateTime(2023, 10, 12), + publisher: 'QA Press', + ); +} + +void main() { + testWidgets('Detail rendering and wishlist toggling', (tester) async { + final book = _fakeBook(); + final fakeWishlist = _FakeWishlistNotifier(initial: []); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + bookDetailProvider.overrideWith((ref, id) async => book), + wishlistProvider.overrideWith(() => fakeWishlist), + ], + child: MaterialApp( + home: BookDetailScreen(id: book.id, initialBook: book), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text(book.title), findsOneWidget); + expect(find.text('by ${book.authorName}'), findsOneWidget); + expect(find.text(book.category), findsOneWidget); + expect(find.text('Rent'), findsOneWidget); + + await tester.tap(find.byTooltip('Add to wishlist')); + await tester.pump(); + + expect(fakeWishlist.toggleCalls, 1); + expect(find.byIcon(Icons.favorite), findsOneWidget); + }); +} diff --git a/test/presentation/screens/explore/explore_screen_test.dart b/test/presentation/screens/explore/explore_screen_test.dart new file mode 100644 index 0000000..a78a9ff --- /dev/null +++ b/test/presentation/screens/explore/explore_screen_test.dart @@ -0,0 +1,29 @@ +// * Widget Testing 2: Explore Screen Integrity Test +import 'package:codexia/presentation/providers/auth_provider.dart'; +import 'package:codexia/presentation/screens/main/explore_screen.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('ExploreScreen shows available explore options', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + authStateProvider.overrideWith((ref) => const Stream.empty()), + ], + child: const MaterialApp(home: ExploreScreen()), + ), + ); + + expect( + find.text('Pick how you want to explore books today.'), + findsOneWidget, + ); + expect(find.text('Open Filters'), findsOneWidget); + expect(find.text('Roll the Dice'), findsOneWidget); + expect(find.byIcon(Icons.tune_outlined), findsOneWidget); + expect(find.byIcon(Icons.auto_awesome_outlined), findsOneWidget); + }); +} From 63799403fee3826bb4259c90adb0af365e6795e8 Mon Sep 17 00:00:00 2001 From: Mohammad Zidane Kurnianto Date: Sat, 31 Jan 2026 20:52:32 +0700 Subject: [PATCH 2/4] feat: Add Dio debugging capability --- lib/data/services/api_client.dart | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/lib/data/services/api_client.dart b/lib/data/services/api_client.dart index c11c6d0..16e0441 100644 --- a/lib/data/services/api_client.dart +++ b/lib/data/services/api_client.dart @@ -1,8 +1,10 @@ +import 'dart:developer'; + import 'package:dio/dio.dart'; -/// Shared HTTP client wrapper to keep Dio config (baseUrl, timeout, interceptors) in one place. +// * Shared HTTP client wrapper to keep Dio config (baseUrl, timeout, interceptors) in one place. class ApiClient { - ApiClient({Dio? dio}) + ApiClient({Dio? dio, bool enableLogging = true}) : _dio = dio ?? Dio( @@ -12,7 +14,30 @@ class ApiClient { receiveTimeout: const Duration(seconds: 15), ), ) { - // Add logging / interceptors here when needed. + // * Enables logging capability + if (enableLogging) { + _dio.interceptors.add( + InterceptorsWrapper( + onResponse: (resp, handler) { + log( + 'API ${resp.requestOptions.method} ${resp.requestOptions.path} -> ${resp.statusCode}', + name: 'ApiClient', + ); + handler.next(resp); + }, + onError: (err, handler) { + log( + 'API ERROR ${err.requestOptions.method} ${err.requestOptions.path} -> ${err.response?.statusCode ?? 'no-status'} ${err.message}', + name: 'ApiClient', + ); + if (err.response?.data != null) { + log('Body: ${err.response?.data}', name: 'ApiClient'); + } + handler.next(err); + }, + ), + ); + } } static const String _baseUrl = From 8dce5fddbd7de58110c2642775c48fbfd504a64c Mon Sep 17 00:00:00 2001 From: Mohammad Zidane Kurnianto Date: Sat, 31 Jan 2026 21:16:01 +0700 Subject: [PATCH 3/4] feat: Add integration testing --- integration_test/app_test.dart | 50 ++++++++++++++++++++++++++++++++++ pubspec.lock | 47 ++++++++++++++++++++++++++++++++ pubspec.yaml | 2 ++ 3 files changed, 99 insertions(+) create mode 100644 integration_test/app_test.dart diff --git a/integration_test/app_test.dart b/integration_test/app_test.dart new file mode 100644 index 0000000..31d66e5 --- /dev/null +++ b/integration_test/app_test.dart @@ -0,0 +1,50 @@ +// Integration test flow: +// 1) Buka Explore screen +// 2) Tap “Open Filters” untuk berpindah halaman FilterScreen. +// 3) Verifikasi +// Connect device/emulator, lalu jalankan `flutter test integration_test` + +import 'package:codexia/presentation/providers/auth_provider.dart'; +import 'package:codexia/presentation/screens/explore/filter_screen.dart'; +import 'package:codexia/presentation/screens/main/explore_screen.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Explore flow: open filter screen and return', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + // Avoid hitting real Firebase auth; keep auth state empty. + authStateProvider.overrideWith((ref) => const Stream.empty()), + ], + child: const MaterialApp(home: ExploreScreen()), + ), + ); + + // On Explore screen + expect( + find.text('Pick how you want to explore books today.'), + findsOneWidget, + ); + expect(find.text('Open Filters'), findsOneWidget); + + // Tap Filter option -> navigates to FilterScreen + await tester.tap(find.text('Open Filters')); + await tester.pumpAndSettle(); + + expect(find.byType(FilterScreen), findsOneWidget); + expect(find.text('Filters'), findsOneWidget); + + // Back to Explore + await tester.pageBack(); + await tester.pumpAndSettle(); + + expect(find.byType(ExploreScreen), findsOneWidget); + }); +} diff --git a/pubspec.lock b/pubspec.lock index 9d835d0..a58aa7b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -374,6 +374,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.2" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_launcher_icons: dependency: "direct dev" description: @@ -448,6 +453,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -512,6 +522,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.7.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" io: dependency: transitive description: @@ -656,6 +671,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -680,6 +703,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" pub_semver: dependency: transitive description: @@ -837,6 +868,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -1029,6 +1068,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" webkit_inspection_protocol: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index af0aed2..3e2362b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,6 +28,8 @@ dev_dependencies: riverpod_generator: ^3.0.3 build_runner: ^2.10.4 flutter_launcher_icons: ^0.14.4 + integration_test: + sdk: flutter flutter: uses-material-design: true From adfc789c08279814688aa0220b51be86dc7508c1 Mon Sep 17 00:00:00 2001 From: Mohammad Zidane Kurnianto Date: Sat, 31 Jan 2026 21:30:12 +0700 Subject: [PATCH 4/4] feat: Configured CI/CD with GitHub Actions and FAD --- .github/workflows/flutter-ci.yml | 56 ++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/flutter-ci.yml diff --git a/.github/workflows/flutter-ci.yml b/.github/workflows/flutter-ci.yml new file mode 100644 index 0000000..b9c37b3 --- /dev/null +++ b/.github/workflows/flutter-ci.yml @@ -0,0 +1,56 @@ +name: Flutter CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build_and_test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Flutter pub get + run: flutter pub get + + - name: Flutter analyze + run: flutter analyze + + - name: Flutter test (unit + widget) + run: flutter test + + firebase_app_distribution: + needs: build_and_test + if: ${{ secrets.FIREBASE_APP_ID_ANDROID != '' && secrets.FIREBASE_TOKEN != '' }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Flutter pub get + run: flutter pub get + + - name: Build APK + run: flutter build apk + + # Terintegrasi langsung dengan FAD + - name: Upload to Firebase App Distribution + uses: wzieba/Firebase-Distribution-Github-Action@v1 + with: + appId: ${{ secrets.FIREBASE_APP_ID_ANDROID }} + token: ${{ secrets.FIREBASE_TOKEN }} + file: build/app/outputs/apk/debug/app-debug.apk + releaseNotes: "Hello from GitHub Actions! :)"